Thinking In Swift

T’has fixat en la quantitat d’aplicacions que ens mustran un mapa en el qual ens situen, ens indiquen llocs interessants propers, marquen rutes …? En aquest article et explicaré com es construeix un aplicació de mapes i rutes amb MapKit.

Però què és MapKit? MapKit és un framework Apple que basa el seu funcionament en l’API i les dades d’Apple Maps, de manera que es poden afegir fàcilment mapes a les aplicacions desenvolupades, en aquest cas, per iOS.

Una mica de Swift

Aquest projecte el pots trobar complet en GitHub.

Disseny de la interfície

Aquest projecte constarà bàsicament d’un component MKMapView, que serà el que ens mostri el mapa, a què anirem afegint diferents components segons les funcionalitats que li vulguem afegir a l’aplicació. A més, en aquest projecte, tot això es farà mitjançant codi, sense utilitzar storyboards o fitxers .xib.

Creació d’el projecte

Per a treballar sense storyboards a l’establir un projecte en Xcode 11 hem de fer uns passos després d’haver creat :

  • eliminem el fitxer Main.storyboard.
  • a la pestanya General (TARGETS), anem a l’selector Main interfície i eliminem Main, deixant el camp en blanc.
Cal esborrar Main i deixar el camp en blanc.

  • Finalment, a la pestanya Informació (TARGETS) anem a Application Scene Manifest > Scene Configuration > Application Session Role > Item 0 (Default Configuration) i eliminem el camp Storyboard Name.
Esborrat de camp Storyboard Name en el fitxer Info.plist.

Com ara ja no direm a l’Main.storyboard per iniciar el projecte, anem a el fitxer SceneDelegate.swift, i en la funció scene (_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) i substituïm el seu contingut pel següent codi:

guard let windowScene = (scene as? UIWindowScene) else { return }window = UIWindow(frame: UIScreen.main.bounds)let viewController = ViewController()window?.rootViewController = viewControllerwindow?.makeKeyAndVisible()window?.windowScene = windowScene

Afegim un mapa a la nostra aplicació

per afegir un mapa a la pantalla, simplement hem de crear una instància de MKMapView i afegir-la a la vista de pantalla. Per a això, en la classe ViewController, en primer lloc hem d’importar la llibreria MapKit, després vam crear una instància de MKMapView i la presentem:

import UIKitimport MapKitclass ViewController: UIViewController { private let mapView = MKMapView(frame: .zero) override func viewDidLoad() { super.viewDidLoad() layoutUI() } private func layoutUI() { mapView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(mapView) NSLayoutConstraint.activate() }}

Si executem l’aplicació podrem veure en pantalla un mapa de aproximat d’on estem situats.

Presentació de mapa.

Perquè aquesta ruta ens mostri exactament, hem d’utilitzar la classe CLLocationManager, que tal com Apple indica, permet iniciar i finalitzar l’enviament d’esdeveniments de localització a la nostra aplicació:

  • Detectar canvis en la posició de l’usuari.
  • Veure canvis en la direcció de la brúixola.
  • Monitoritzar regions d’interès.
  • Detectar la posició de balises (beacons) properes.

Permisos

Cal tenir en compte que per poder fer servir les funcions de localització, abans hem de demanar permís a l’ usuari. Per a això, hi afegim al fitxer Info.plist, una sèrie de paràmetres (tal com vaig mostrar també per utilitzar notificacions):

  • Privacy – Location Always and When In Utilitza Usage Descripció
  • Privacy – Location Always Usage Descripció
  • Privacy – Location When In Utilitza Usage Descripció

als que els donem com a valor el missatge que volem mostrar a l’usuari per demanar-li permís (en aquest exemple, ‘Allow location access in order to use this app.’).

Modificació de el fitxer Info.plist per a la petició de permisos.

Un cop modificat el fitxer Info.plist, anem a fer que l’aplicació comprovi, primer, si esán habilitats els serveis de localització i, després, si l’ususari ha donat permís i quin tipus de permís.El que fem en primer lloc és crear una instància de la classe CLLocationManager:

private let locationManager = CLLocationManager()

I després, vam crear el mètode checkLocationService, en el que comprovarem si estan habilitats els serveis de localització en el dispositiu i, en cas afirmatiu, establirem el delegat (delegate) per a aquesta classe tal com s’indica a la documentació i indicarem amb què precisió volem que treballi la localització:

private func checkLocationServices() { guard CLLocationManager.locationServicesEnabled() else { // Here we must tell user how to turn on location on device return } locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest}

Aquesta funció l’anomenarem des del mètode viewDidLoad. Alhora que establim el delegat, adoptem un parell de mètodes d’aquest delegat que ens permetran conèixer si canvia el permís donat per l’usuari sobre l’ús de la localització (locationManager (_: didChangeAuthorization :)) i quan s’actualitza la localització de l’ usuari (locationManager (_: didUpdateLocations :)) (això ho fem en una extensió de la classe ViewController per tenir el codi organitzat):

extension ViewController: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: ) { }}

Ara podem seguir completant la classe ViewController afegint el mètode que mirarà si l’aplicació té permís, i quin tipus de permís, per utilitzar la localització. En aquest mètode el que fem és cridar a l’mètode authorizationStatus de la classe CLLocationManager i comprovar que valor obtenim:

private func checkAuthorizationForLocation() { switch CLLocationManager.authorizationStatus() { case .authorizedWhenInUse, .authorizedAlways: mapView.showsUserLocation = true locationManager.startUpdatingLocation() break case .denied: // Here we must tell user how to turn on location on device break case .notDetermined: locationManager.requestWhenInUseAuthorization() case .restricted: // Here we must tell user that the app is not authorize to use location services break @unknown default: break }}

Tal com es pot observar, hi ha diferents possibilitats pel que fa a l’autorització d’l’ús de la localització:

  • authorizedWhenInUse. L’usuari va autoritzar l’aplicació per iniciar els serveis d’ubicació mentre està en ús.
  • authorizedAlways. L’usuari va autoritzar l’aplicació per iniciar els serveis d’ubicació en qualsevol moment.
  • denied. L’usuari va rebutjar l’ús dels serveis d’ubicació per a l’aplicació o estan deshabilitats globalment en Paràmetres.
  • notDetermined. L’usuari no ha triat si l’aplicació pot utilitzar serveis d’ubicació.
  • restricted. L’aplicació no està autoritzada per utilitzar els serveis de la ciutat.

Aquesta funció, checkAuthorizationForLocation, l’anomenarem en dos punts:

  • En el mètode checkLocationServices () després d’establir el delegat, després d’entrar en l’aplicació.
  • en el mètode locationManager (_: didChangeAuthorization :), per si canvia l’autorització de l’usuari durant l’ús de l’aplicació.
private func checkLocationServices() { guard CLLocationManager.locationServicesEnabled() else { // Here we must tell user how to turn on location on device return } locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest checkAuthorizationForLocation()}func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { checkAuthorizationForLocation()}

Si ara executem l’aplicació, veurem com ens mostra un avís demanant permís per utilitzar els servicis de localització.

.

Un cop donem permís a la apliació per utilitzar els serveis de localització, el que hem de fer és dir-li que ja pot activar el seguiment de la posició de el dispositiu. Per a això, dins de l’mètode checkAuthorizationForLocation () i dins dels casos que permeten l’ús afegim el següent codi:

case .authorizedWhenInUse, .authorizedAlways: mapView.showsUserLocation = true centerViewOnUser() locationManager.startUpdatingLocation() break

El que estem fent aquí és a dir que la instància de MKMapView ha de mostrar la posició de l’usuari (mapView.showsUserLocation = true), que centri la vista en l’usuari (mètode que crearem ara) i que s’activi l’actualització de la localització.

Mostra nostra posició al mapa

el mètode centerViewOnUser (), el que fa és determinar a partir de la localització de l’usuari, establir un regió rectangular centrada és punt. Per a això utilitzem MKCoordinateRegion.

private let rangeInMeters: Double = 10000private func centerViewOnUser() { guard let location = locationManager.location?.coordinate else { return } let coordinateRegion = MKCoordinateRegion.init(center: location, latitudinalMeters: rangeInMeters, longitudinalMeters: rangeInMeters) mapView.setRegion(coordinateRegion, animated: true)}

Aquí, primer ens assegurem que tenim la posició de l’usuari. Després, establim una regió de 10 x 10 km centrada en l’usuari. Finalment, establim aquesta regió al mapa. D’aquesta manera, obtenim la següent imatge en el dispositiu.

Finalment, per poder actulizar la posició de l’usuari al mapa, en el mètode locationManager (_: didUpdateLocations 🙂 fem una cosa semblant al que hem fet per centrar la vista al usuari:

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: ) { guard let location = locations.last else { return } let coordinateRegion = MKCoordinateRegion.init(center: location.coordinate, latitudinalMeters: rangeInMeters, longitudinalMeters: rangeInMeters) mapView.setRegion(coordinateRegion, animated: true)}

Però en aquest cas, la localització l’obtenim de l’últim valor de la llista de localitzacions que retorna el mètode.

Selecciona el tipus de mapa

el mapa que veiem per defecte a l’encendre l’aplicació és el de tipus estàndard. MapKit permet mostrar diferents tipus de mapes canviant el valor de l’paràmetre mapType de la instància de MKMapView:

  • standard. Un mapa de carrers que mostra la posició de totes les carreteres i alguns noms de carreteres.
  • satellite. Imatges per satèl·lit de la zona.
  • hybrid. Una imatge via satèl·lit de l’àrea amb informació sobre la carreteres ysu nom (en una capa per sobre de mapa).
  • satelliteFlyover.Una imatge via satèl·lit de l’àrea amb dades de la zona (on estiguin disponibles).
  • hybridFlyover. Una imatge via satèl·lit híbrida amb dades de la zona (on estiguin disponibles).
  • muteStandard. Un mapa de carrers on es ressalten les nostres dades sobre els detalls de mapa.

En aquest cas, només utilitzarem tres tipus de mapes: standard, satellite i hybrid.

La selecció de el tipus de mapa que volem mostrar en l’aplicació ho farem mitjançant un botó amb menú desplegable, que es pot descarregar com a paquet Swift (FABbutton). Per això seguim aquests passos:

  • Des del menú d’Xcode File > Swift Package > Add Package Dependecy … afegim el component FABButton. La URL és: https://github.com/raulferrerdev/FABButton.git
  • Tot seguit, vam crear una instància de botó ii la seva configuració (les icones utilitzades estan ja inclosos en el projecte):
private let mapTypeButton = FABView(buttonImage: UIImage(named: "earth")override func viewDidLoad() { super.viewDidLoad() ... configureMapTypeButton() ...} private func configureMapTypeButton() { mapTypeButton.delegate = self mapTypeButton.addSecondaryButtonWith(image: UIImage(named: "map")!, labelTitle: "Standard", action: { self.mapView.mapType = .mutedStandard }) mapTypeButton.addSecondaryButtonWith(image: UIImage(named: "satellite")!, labelTitle: "Satellite", action: { self.mapView.mapType = .satellite }) mapTypeButton.addSecondaryButtonWith(image: UIImage(named: "hybrid")!, labelTitle: "Hybrid", action: { self.mapView.mapType = .hybrid }) mapTypeButton.setFABButton()}

  • Com es pot observar, hem establert el delegat per al tipus FABView, de manera que haurem de fer que la classe ViewController compleixi aquest protocol. Per això afegim les següents extensió a el projecte:
extension ViewController: FABSecondaryButtonDelegate { func secondaryActionForButton(_ action: @escaping () -> ()) { action() }}

  • Finalment, en el mètode layoutUI afegim el botó a la vista i indiquem la seva posicón:
private func layoutUI() { ... view.addSubview(mapTypeButton) NSLayoutConstraint.activate()}

Si ejectutamos l’aplicació, podrem comprovar que podem anar canviant de tipus de mapa:

Mostra adreces

Ara, el que anem a fer és mostrar en pantalla la direcció d’un punt de mapa (al centre) a parir de les seves coordenades, que obtindrem mitjançant la classe CLLocation. Per a això vam crear una funció getCenterLocation, a la qual vam passar la instància de MKMapView que tenim i ens retornarà les coordenades del punt central:

func getCenterLocation(for mapView: MKMapView) -> CLLocation { let coordinates = mapView.centerCoordinate return CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)}

Per saber exactament quin és el centre de mapa, col·locarem una icona al centre de mapa. Això ho farem amb un element UIImageView, amb la imatge d’un pin (que obtindrem de la llibreria SF Symbols d’Apple).

private let pointer = UIImageView(image: UIImage(systemName: "mappin"))private func layoutUI() { ... pointer.translatesAutoresizingMaskIntoConstraints = false pointer.tintColor = .red ... view.addSubview(pointer) NSLayoutConstraint.activate()}

perquè el la part inferior de el pin quedi exactament al centre de mapa, desplacem aquesta icona cap amunt la meitat de la seva altura (-14.5px).

a més, per mostrar la direcció col·locarem una etiqueta a la part superior de la pantalla, tal com s’ha mostrat en el disseny. Per a això, vam crear una instància de UILabel, la configurem i la situem en pantalla:

private let addressLabel = UILabel(frame: .zero)private func configureAddressLabel() { addressLabel.translatesAutoresizingMaskIntoConstraints = false addressLabel.font = .systemFont(ofSize: 18.0, weight: .medium) addressLabel.textColor = .darkText addressLabel.textAlignment = .center addressLabel.backgroundColor = .init(white: 1, alpha: 0.75) addressLabel.layer.cornerRadius = 5.0 addressLabel.clipsToBounds = true}private func layoutUI() { ... view.addSubview(addressLabel) NSLayoutConstraint.activate()}

Obtenció de la direcció

Per obtenir l’adreça d’un lloc a partir de les seves coordenades, utilitzarem la classe CLGeocoder, que tal com indica la documentació d’Apple, permet obtenir a partir de la longitud i la latitud d’un punt una representació ‘user-friendly ‘d’aquesta localització:

The CLGeododer class provides services for converting between a coordinate (specified es a latitude and longitude) and the user-friendly representation of that coordinate. A user-friendly La representació de of the coordinate Typically consists of the street, city, state, and country information corresponding to the given location, but it may also contain a rellevant point of interest, landmarks, or other Identifying information.

Apple documentation (CLGeocoder)

Per poder conèixer les coordenades del punt central de mapa, implementarem el delegat de MKMapView i el mètode que recull cada vegada que desplacem el mapa.

private let geoCoder = CLGeocoder()private var previousLocation: CLLocation?extension ViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { let center = getCenterLocation(for: mapView) guard let previousLocation = self.previousLocation, center.distance(from: previousLocation) > 25 else { return } self.previousLocation = center geoCoder.reverseGeocodeLocation(center) { (placemarks, error) in guard let self = self else { return } if let _ = error { // Show alert for the user return } guard let placemark = placemarks?.first else { // Show alert for the user return } let streetNumber = placemark.subThoroughfare ?? "" let streetName = placemark.thoroughfare ?? "" DispatchQueue.main.async { self.addressLabel.text = "\(streetNumber) \(streetName)" } } }}

Dins d’aquest mètode fem el següent:

  • En primer lloc obtenim les coordenades de centre de l’ mapa (gràcies a la funció getCenterLocation, que hem vist anteriorment).
  • el següent pas és saber si hi ha una posició prèvia (previousLocation, que hem instanciat a el principi) i, en aquest cas, comprovar que la diferència a la distància a la nova posició és superior, en aquest cas, a 25 m. Si es compleixen aquestes condicions, s’assigna el valor de les noves coordenades a la variable previousLocation.
  • Tot seguit, prenem una instància de la funció CLGeocoder i cridem a mètode reverseGeocodeLocation, a què passem les coordenades de centre de la pantalla.
  • Aquesta funció retorna un bloc amb dos paràmetres:
typealias CLGeocodeCompletionHandler = (?, Error?) -> Void

  • D’aquests dos valors, comprovem que no s’ha produït cap error i que ha tornat una posició. Un objecte CLPlacemark emmagatzema dades referents a una latitud i longitud determinades (com, per exemple, el país, l’estat, la ciutat i la direcció del carrer, punts d’interès i dades geogràficament relacionats).
  • D’aquesta informació ens interessa dos paràmetres: Thoroughfare (que és la direcció del carrer associada a la posició indicada) i subThoroughfare (que dóna informació addicional sobre aquesta direcció).
  • Finalment , i en el fil principal, afegim aquesta informació a l’etiqueta.

Establir rutes

Ara, el que ens queda per fer en aquest projecte és implementar un sistema de rutes. És a dir, a partir d’un punt d’origen i un altre de destinació, establir les rutes òptimes per al recorregut.

Això podem fer-ho de manera senzilla gràcies a MapKit. En aquest cas utilitzarem la classe MKDirections.Request, que ens permet establir una petició (request) en la qual indiquem el punt d’origen, el de destinació, el tipus de transport, si volem que se’ns mostrin rutes alternatives …

  • var source MKMapItem ?. És el punt de partida de les rutes.
  • var destination: MKMapItem ?. És el puntod destí de les rutes.
  • var transportType: MKDirectionsTransportType. És el tipus de transport que s’aplica per calcular les rutes. Pot ser automobile, walking, transit or any.
  • var requestsAlternateRoutes: Bool. Indica si volem rutes alternatives, en cas que estiguin disponibles.
  • var departureDate: Dóna’t ?. És la data de partida d’el viatge.
  • var arrivalDate: Dóna’t ?. És la data d’arribada de el viatge.

El que fem és crear un mètode que ens retornarà un objecte de l’tipus MKDirections.Request:

func createRequest() -> MKDirections.Request? { guard let coordinate = locationManager.location?.coordinate else { return nil } let destinationCoordinate = getCenterLocation(for: mapView).coordinate let origin = MKPlacemark(coordinate: coordinate) let destination = MKPlacemark(coordinate: destinationCoordinate) let request = MKDirections.Request() request.source = MKMapItem(placemark: origin) request.destination = MKMapItem(placemark: destination) request.transportType = .automobile request.requestsAlternateRoutes = true return request}

En aquest mètode, primer obtenim les coordenades del punt central de la pantalla. Després vam crear objectes tipus MKPlacemark amb les coordenades d’origen (el punt en què ens trobem) i de destinació. Finalment, vam crear una instància de tipus MKDirections.Request amb indicant l’origen, la destinació, el tipus de transport (automòbil) i que volem rutes alternatives.

Per dibuixar la ruta, ho he hem de fer és iniciar un objecte d’top MKDirections amb la petició (request) que hem creat. Tal com indica la documentació d’Apple, aquest objecte calcula adreces i informació de temps de viatge en funció de la informació de ruta que proporcioni.

Per tant, crearem un mètode que a partir de la creació d’una petició (request), obtingui un objecte de tipus MKDirections i representi les rutes:

func drawRoutes() { guard let request = createRequest() else { return } let directions = MKDirections(request: request) directions.calculate { (response, error) in guard let response = response else { return } let routes = response.routes for route in routes { self.mapView.addOverlay(route.polyline) self.mapView.setVisibleMapRect(route.polyline.boundingMapRect, animated: true) } }}

En aquest mètode, un cop obtingut l’objecte MKDirections, utilitzem el mètode func calculate (completionHandler: MKDirections.DirectionsHandler), que ens retorna un objecte tipus MKDirections.Response i un possible error:

typealias DirectionsHandler = (MKDirections.Response?, Error?) -> Void

Llavors vam comprovar que la resposta és vàlida i prenem el paràmetres routes, que és un llista d’objectes tipus MKRoute que representen els recorreguts entre els llocs d’origen i destinació.

Si ens fixem en la documentació d’Apple, l’objecte MKRoute presenta un paràmetre denominat polyline, que conté el traçat de la ruta. Per representar aquest traçat, el que hem fet és passar aquest paràmetre a el mètode addOverlay (_ overlay: MKOverlay) de l’objecte MKMapView. Després vam canviar la part visible de l’mapa amb el mètode setVisibleMapRect (_ mapRect: MKMapRect, animated animate: Bool).

A més, hem d’afegir un mètode d’el protocol MKMapViewDelegate perquè es dibuixin les rutes (dibuixant en color verd i d’un gruix de 5px):

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { let renderer = MKPolylineRenderer(overlay: overlay as! MKPolyline) renderer.strokeColor = .green renderer.lineWidth = 5 return renderer}

Ara, el que ens cal és afegir un botó que ens permeti iniciar el càlcul de la ruta. A partir del disseny de la interfície que hem mostrat a del principi, afegim i configurem un element UIButton, a què afegirem com target el mètode drawRoutes ():

private let startButton = UIButton(frame: .zero) override func viewDidLoad() { super.viewDidLoad() ... configureStartButton()}private func configureStartButton() { startButton.translatesAutoresizingMaskIntoConstraints = false startButton.setTitle("Start", for: .normal) startButton.backgroundColor = .systemRed startButton.setTitleColor(.white, for: .normal) startButton.titleLabel?.font = .systemFont(ofSize: 18.0, weight: .bold) startButton.layer.cornerRadius = 5.0 startButton.clipsToBounds = true startButton.addTarget(self, action: #selector(drawRoutes), for: .touchUpInside)}private func layoutUI() { ... view.addSubview(startButton) ... NSLayoutConstraint.activate()}extension ViewController: MKMapViewDelegate { ... func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { let renderer = MKPolylineRenderer(overlay: overlay as! MKPolyline) renderer.strokeColor = .green renderer.lineWidth = 5 return renderer }}

Conclusions

La llibreria MapKit d’Apple permet desenvolupar de forma senzilla una aplicació que mostri mapes, mostri la nostra posició al mapa, ens mostri adreces a partir d’un punt seleccionat al mapa i les rutes per arribar a aquesta direcció.

Deixa un comentari

L'adreça electrònica no es publicarà. Els camps necessaris estan marcats amb *