Aprende MapKit y CoreLocation en SwiftUI
Aprende MapKit y CoreLocation en SwiftUI

MapKit y CoreLocation en SwiftUI (localización en tiempo real)

MapKit en SwiftUI permite mostrar un mapa y CoreLocation en SwiftUI permite saber la localización de un user en tiempo real. Primero tenemos que usar CLLocationManager para que el user autorice recoger la localización. Aprende a crear una app mostrando la localización de un user en tiempo real

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
MapKit y CoreLocation en SwiftUI
MapKit y CoreLocation en SwiftUI

Hoy en SwiftBeta vamos a aprender a mostrar la localización de un user en tiempo real en un mapa en SwiftUI. Vamos usar dos frameworks llamados MapKit y CoreLocation. Los dos necesarios para cumplir con los propósitos del post de hoy.

MapKit en código

Lo primero de todo es crear un proyecto de cero en Xcode. Y dentro del ContentView vamos a crear dos struct privadas con la información de unas coordenadas que queremos mostrar en el mapa y también el zoom que queremos que tenga nuestro mapa.

El código del ContentView quedaría de la siguiente manera:

struct ContentView: View {
    private struct DefaultRegion {
        static let latitude = 9.9333
        static let longitude = -84.0833
    }
    
    private struct Span {
        static let delta = 0.1
    }
    
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
Latitud y Longitud en SwiftUI

Lo siguiente que vamos hacer es irnos a la parte de arriba del fichero e importar el framework MapKit. Y dentro de la vista ContentView, en la propiedad body vamos a poner Map. Al abrir parétensis podemos ver todos los inicializadores, nos quedamos con el que esperar un coordinateRegion y un showsUserLocation.

El parámetro coordinateRegion es de tipo MKCoordinateRegion, y debemos inicializarlo con las structs privadas que hemos creado al principio del video. Para darle un valor inicial, creamos una propiedad @State y inicializamos MKCoordinateRegion, el código final sería:

import SwiftUI
import MapKit

struct ContentView: View {
    private struct DefaultRegion {
        static let latitude = 9.9333
        static let longitude = -84.0833
    }
    
    private struct Span {
        static let delta = 0.1
    }
    
    @State var coordinateRegion: MKCoordinateRegion = .init(center: CLLocationCoordinate2D(latitude: DefaultRegion.latitude, longitude: DefaultRegion.longitude),
                                                            span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))

    var body: some View {
        Map(coordinateRegion: $coordinateRegion, showsUserLocation: true)
            .ignoresSafeArea()

    }
}
Map en SwiftUI

Si ahora compilamos veremos que aparece la vista del mapa y sale San José, que son las coordenadas que hemos puesto en la struct DefaultRegion.

Aquí puedes jugar con el Span para ver como la vista del mapa se acerca o se aleja dependiendo de los valores que pongas
Mostrando Map en SwiftUI

Hasta aquí todo muy sencillo, hemos importado un framework que no habíamos visto hasta ahora llamado MapKit, hemos usado la vista Map y hemos usado también un tipo llamado MKCoordinateRegion para mostrar una región de un mapa.

Ahora lo que vamos hacer es mostrar la posición de un usuario en el mapa.

CoreLocation en código

Ahora lo que vamos hacer es crear un fichero nuevo, va a ser una clase y la vamos a llamar LocationViewModel. Esta clase va a ser final y de momento va a conformar el protocolo NSObject. Vamos a crear una propiedad de tipo CLLocationManager y la vamos a inicializar.  Vemos que hay un error, para arreglarlo importamos el framework CoreLocation, este framework nos permite acceder a información de la localización de un user, saber si nos ha autorizado saber su localización en nuestra app, etc (todo esto lo vamos a ver a continuación).

Cremos un init y especificamos que queremos la mejor precisión, especificamos que queremos saber la localización mientras se esté usando la app. Especificamos que queremos empezar a recibir la localización del user y asignamos que la clase Location sea el delegado de nuestra propiedad locationManager,

De momento el código nos quedaría así:

import CoreLocation

final class LocationViewModel: NSObject {
    private let locationManager: CLLocationManager = .init()
    
    override init() {
        super.init()
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
        locationManager.delegate = self
    }
}
Init de CoreLocation en Swift

¿esto del delegado qué significa? hay "algo" mágico que nos va a ir notificando de actualizaciones en la localización del user, y nos notificará dentro de nuestra clase Location. Para ello debemos conformar un protocolo e implementar un método:

extension LocationViewModel: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        
        print("Location \(location)")
    }
}
Obtener localización del delegado CLLocationManagerDelegate

Lo siguiente que vamos hacer es crear una propiedad en la vista ContentView de tipo LocationViewModel y la vamos a instanciar. El código que quedaría:

import SwiftUI
import MapKit

struct ContentView: View {
    private struct DefaultRegion {
        static let latitude = 9.9333
        static let longitude = -84.0833
    }
    
    private struct Span {
        static let delta = 0.1
    }
    
    var location = LocationViewModel()
    @State var coordinateRegion: MKCoordinateRegion = .init(center: CLLocationCoordinate2D(latitude: DefaultRegion.latitude, longitude: DefaultRegion.longitude),
                                                            span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))

    var body: some View {
        Map(coordinateRegion: $coordinateRegion, showsUserLocation: true)
            .ignoresSafeArea()
    }
}
Instanciamos el ViewModel en SwiftUI

Ahora si compilamos la app, seguimos viendo la misma vista de antes donde aparece San José, y si te fijas vemos un error por consola que nos dice lo siguiente:

This app has attempted to access privacy-sensitive data without a usage description. The app's Info.plist must contain an “NSLocationWhenInUseUsageDescription” key with a string value explaining to the user how the app uses this data

Para poder recibir la localización del user, el user préviamente debe autorizarlo dentro de nuestra app y dar su consentimiento. Ya lo habrás visto en otras apps, a continuación te muestro una captura y sabrás exactamente lo que hablo.

Pedimos permiso a user para obtener su localización

Dentro de este alert, aparece un mensaje, esto es lo que debemos especificar en nuestro Info.plist de nuestra app. Para ello vamos al Info.plist

Añadimos la key NSLocationWhenInUseUsageDescription a Info.plist

Pulsamos en una key cualquiera y pulsamos la tecla enter, al hacerlo se crea un campo nuevo y añadimos la key NSLocationWhenInUseUsageDescription y ponemos un mensaje que aparecerá en el alert, en mi caso he puesto Quiero acceder a tu localización para mostrarla en un mapa

Añadimos NSLocationWhenInUseUsageDescription con un mensaje

Una vez hemos añadido esta key en el Info.plist podemos compilar nuestra app y nos aparecerá el alert preguntándonos si queremos autorizar a que nuestra app vaya recibiendo la localización del user.

Autorización para obtener la localización en SwiftUI
Fíjate que el mensaje que hemos añadido a la key NSLocationWhenInUseUsageDescription aparece en el alert. Podemos poner el mensaje que queramos siempre que le quede claro al user para qué queremos obtener su localización.

¡Vamos a continuar! si autorizamos, y pulsamos "Allow While Using App" verás que automáticamente se printa algo por consola.

Location <+41.38790000,+2.16992000> +/- 5.00m (speed -1.00 mps / course -1.00) @ 10/1/21, 5:12:58 PM Central European Summer Time

Al autorizar poder mostrar la localización del user, el método func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) del delegado CLLocationManagerDelegate donde teníamos un print, nos muestra nuestra primera coordenada. Ahora, lo que estaría bien es poder mostrar esta localización en el mapa, vamos a ello.

Vamos a crear una nueva propiedad en LocationViewModel y va a ser de tipo @Published, así que cada cambio que recibamos en el método del delegado, actualizará esta propiedad y se refrescará la vista del Map del ContentView con la nueva localización. La clase LocationViewModel quedaría:

import Foundation
import CoreLocation
import MapKit

final class LocationViewModel: NSObject, ObservableObject {
    private struct Span {
        static let delta = 0.1
    }
    
    private let locationManager: CLLocationManager = .init()
    @Published var userLocation: MKCoordinateRegion = .init()
    
    override init() {
        super.init()
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
        locationManager.delegate = self
    }
}

extension LocationViewModel: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        
        print("Location \(location)")
        userLocation = .init(center: location.coordinate,
                             span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))
    }
}
Creamos @Published para actualizar la vista con la localización del user
fíjate que para poder tener propiedades @Published en LocationViewModel hemos  tenido que conformar el protocolo ObservableObject

y por lo tanto en ContentView podemos simplificar el código que teníamos a:

struct ContentView: View {
    @StateObject var locationViewModel = LocationViewModel()

    var body: some View {
        Map(coordinateRegion: $locationViewModel.userLocation, showsUserLocation: true)
            .ignoresSafeArea()
    }
}
Limiamos ContentView y usamos el LocationViewModel

Si compilamos la app, vemos que nuestro mapa ahora mismo nos situa en Barcelona, y aparece la localización del user en Plaza Cataluña.

Al compilar aparece la alocalización del user en el mapa

Hemos avanzado bastante con muy pocas líneas de código. Ahora lo que vamos hacer es ver qué pasa si un user no autoriza a que recibamos su localización en tiempo real.

authorizationStatus y locationManagerDidChangeAuthorization

Lo que vamos hacer ahora es borrar la app del simulador, ¿por qué? hemos hecho el camino fácil, vamos a ver qué pasa cuando el user no nos autoriza a que recibamos su localización.

Sin localización el mapa nos muestra una zona que desconocemos

Al instalar la app de cero, no tenemos localización y por lo tanto si te fijas estamos en medio de un océano.

Spoiler: Esto lo arreglaremos en breves y daremos una localización por defecto. Así en lugar de que nos muestre una posición en medio del Atlántico, nos mostrará una por defecto. Por ejemplo, otra vez San José.

Vamos a ver qué pasa si le damos a "Dont Allow"

Zona del mapa por defecto cuando no tenemos localización

Pues lo que pasa es que el alert desaparece, y el mapa nos muestra lo que comentábamos, una porción del Atlántico. Vamos a dar una posición por defecto cuando no tengamos la localización del user.

Para ello nos vamos al LocationViewModel, y creamos la misma struct que teniamos antes con las coordenadas de San José.

private struct DefaultRegion {
   static let latitude = 9.9333
   static let longitude = -84.0833
}

y también instanciamos la propiedad userLocation con estos parámetros, el código del LocationViewModel debería parecerse a esto:

import Foundation
import CoreLocation
import MapKit

final class LocationViewModel: NSObject, ObservableObject {
    private struct DefaultRegion {
        static let latitude = 9.9333
        static let longitude = -84.0833
    }
    
    private struct Span {
        static let delta = 0.1
    }
    
    private let locationManager: CLLocationManager = .init()
    @Published var userLocation: MKCoordinateRegion = .init()
    
    override init() {
        super.init()
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
        locationManager.delegate = self
        userLocation = .init(center: CLLocationCoordinate2D(latitude: DefaultRegion.latitude, longitude: DefaultRegion.longitude),
                             span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))
    }
}

extension LocationViewModel: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        
        print("Location \(location)")
        userLocation = .init(center: location.coordinate,
                             span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))
    }
}
Inicializamos una localización por defecto

Ahora si compilamos, en lugar de mostrarnos una zona del Atlántico, nos muestra la zona de San José.
Lo siguiente que vamos hacer, es cambiar la vista, queremos saber si un user ha autorizado a que podamos recibir actualizaciones de su localización, y en caso de no autorizarnos queremos cambiar la vista y mostrar un Link (un Button) para que vaya a la pantalla de Settings de la app y tenga la posibilidad de cambiar lo que escogió por primera vez. Vamos a añadir nuestra última propiedad en LocationViewModel, la vamos a llamar userHasLocation y será de tipo Booleano. La modificaremos a true o false dependiendo de lo que escoja el usuario cuando le aparezca el alert.

Una vez creada la propiedad:

@Published var userHasLocation: Bool = false

Vamos a crear un método en LocationViewModel para saber qué escoge el user cuando le aparece el alert de la autorización de la localización. Es una función muy simple, solo debemos usar la variable authorizationStatus de nuestra propiedad locationManager, y obtendremos qué estado tiene:

    func checkUserAuthorization() {
        let status = locationManager.authorizationStatus
        switch status {
        case .authorized, .authorizedAlways, .authorizedWhenInUse:
            userHasLocation = true
            break
        case .denied, .notDetermined, .restricted:
            print("User no ha autorizado mostrar su localización")
            userHasLocation = false
        @unknown default:
            print("Unhandled state")
        }
    }
Comprobamos si el user nos dió permisos para la autorización o no en SwiftUI

Ahora, vamos a llamar en otro método que pertenece al Protocolo CLLocationManagerDelegate (y del que ya conforma LocationViewModel). Dentro de este método, cuando haya un cambio la autorización, llamaremos a la función que acabamos de crear para que actualice la propiedad userHasLocation (y por lo tanto actualice la vista, que es lo que veremos a continuación)

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        checkUserAuthorization()
    }
Comprobamos cambios en la autorización de la localización en SwiftUI

Es decir, nuestro LocationViewModel, nuestra versión final quedaría:

import SwiftUI
import CoreLocation
import MapKit

final class Location: NSObject, ObservableObject {
    private struct DefaultRegion {
        static let latitude = 9.9333
        static let longitude = -84.0833
    }
    
    private struct Span {
        static let delta = 0.1
    }
    
    private let locationManager: CLLocationManager = .init()
    @Published var userLocation: MKCoordinateRegion = .init()
    @Published var userHasLocation: Bool = false
    
    override init() {
        super.init()
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
        locationManager.delegate = self
        userLocation = .init(center: CLLocationCoordinate2D(latitude: DefaultRegion.latitude, longitude: DefaultRegion.longitude),
                             span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))
    }
    
    func checkUserAuthorization() {
        let status = locationManager.authorizationStatus
        switch status {
        case .authorized, .authorizedAlways, .authorizedWhenInUse:
            userHasLocation = true
            break
        case .denied, .notDetermined, .restricted:
            print("User no ha autorizado mostrar su localización")
            userHasLocation = false
        @unknown default:
            print("Unhandled state")
        }
    }
}

extension Location: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        
        userLocation = .init(center: location.coordinate,
                             span: .init(latitudeDelta: Span.delta, longitudeDelta: Span.delta))
        userHasLocation = true
    }
    
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        checkUserAuthorization()
    }
}
Clase LocationViewModel

Ahora, como hemos dicho, tenemos que escuchar estos cambios en la vista para poder actualizarla, lo que vamos hacer es mostrar dos vistas diferentes abajo de la vista Map

  • Si el user acepta a que recibamos su localización mostramos el Map y abajo un Text indicando que el user ha aceptado.
  • Si el user no acepta que recibamos su localización mostramos el Map y abajo un Link, un Button para ir a la pantalla de Settings de la app y pueda modificar el estado de la autorización.

El código de ContentView nos queda así:

import SwiftUI
import MapKit

struct ContentView: View {
    @StateObject var locationViewModel = LocationViewModel()

    var body: some View {
        VStack {
            Map(coordinateRegion: $locationViewModel.userLocation, showsUserLocation: true)
                .ignoresSafeArea()
                .task {
                    locationViewModel.checkUserAuthorization()
                }
            if locationViewModel.userHasLocation {
                Text("Localización Aceptada ✅")
                    .bold()
                    .padding(.top, 12)
                Link("Pulsa para cambiar la autorización de Localización", destination: URL(string: UIApplication.openSettingsURLString)!)
                .padding(32)
            } else {
                Text("Localización NO Aceptada ❌")
                    .bold()
                    .padding(.top, 12)
                Link("Pulsa para aceptar la autorización de Localización", destination: URL(string: UIApplication.openSettingsURLString)!)
                .padding(32)
            }
        }
    }
}
Actualizamos vista dependiendo de si el user autorizó o no la localización

Como lo último que teníamos era que el user no había aceptado las notificaciones. (Es lo que hemos escogido y por eso nos aparecía la vista del Atlántico y lo hemos modificado para que apareciese San José.). El user no tiene localización y por lo tanto aparece esta vista

Vista final de nuestro mapa en SwiftUI

y si pulsamos el Link (el Button) que aparece en la parte inferior, nos lleva a la siguiente vista:

Settings generales de iOS

Lo que debemos hacer es que nos lleve a las Settings de la app que estamos creando, no a la Settings generales de iOS. Para poder hacerlo, debemos crear un nuevo fichero.
Pulsamos COMMAND+N y escogemos el que pone "Settings Bundle"

Creamos un nuevo fichero de tipos Settings Bundle

Le damos a Next y lo creamos. Nos aparecerá en el listado de ficheros en Xcode (en la parte izquierda). Ahora, si compilamos otra vez y le damos al Link (al Button) que aparece debajo de la vista Map, nos lleva directamente a la sección de Settings de nuestra app, y aquí podemos cambiar la opción de Location

Al compilar la app y pulsar en el Link vamos a las settings de nuestra app

si te fijas, a mi me aparece con el valor Never, si la modifico y selecciono "While Using the App" y vuelvo abrir la app (sin necesidad de compilar) me muestra directamente que estoy en Barcelona.

Podemos cambiar la autorización de la localización cuando queramos

De esta manera los users que no aceptaron la localización de tu app, tienen otra oportunidad para aceptarla.

Conclusión

Hoy hemos aprendido a usar MapKit y CoreLocation en SwiftUI. Hemos aprendido también varios tipos nuevos que son usados dentro de estos frameworks. Con muy pocas líneas de código hemos podido mostrar una posición en el mapa en SwiftUI y más tarde hemos aprendido a cómo sacar la localización en tiempo real de un user.