SwiftUI - interacting with UIKit - part1: map view

SwiftUI comes with good integration with existing UIKit framework. It is possible to achieve SwiftUI interaction with UIKit by wrapping UIViewControllers into SwiftUI views and the other way around, embed SwiftUI views into view controllers. In this post, I will show you an example of how to include the MapKit functionality in your SwiftUI app, wrapping a view controller.
The purpose of this demo is to create a view, containing a search bar and a map view. The user types in a place in the search bar; the first result in the list of results retrieved by the map search is added to the map as a pin. Finnaly, I will show how to interact with the outside view by creating a delegate for the controller.
1. Start with a view controller
I create a view controller and I programatically include all the elements that I want to show in my view: a search bar and a map view:

 class MapViewController: UIViewController {   
      var mapView: MKMapView!   
      fileprivate var searchBar: UISearchBar!   
      fileprivate var localSearchRequest: MKLocalSearch.Request!   
      fileprivate var localSearch: MKLocalSearch!   
      fileprivate var localSearchResponse: MKLocalSearch.Response!    
      fileprivate var annotation: MKAnnotation!   
      fileprivate var isCurrentLocation: Bool = false   
      var selectedPin: MKPlacemark?   
      override func viewDidLoad() {   
           super.viewDidLoad()    
           mapView = MKMapView()    
           let leftMargin:CGFloat = 10          
           let topMargin:CGFloat = 60          
           let mapWidth:CGFloat = view.frame.size.width-20          
           let mapHeight:CGFloat = view.frame.size.height - 100              
           mapView.frame = CGRect(x: leftMargin, y: topMargin, width: mapWidth, height: mapHeight)   
           mapView.isZoomEnabled = true          
           mapView.isScrollEnabled = true   
           self.view.addSubview(mapView)    
           searchBar = UISearchBar(frame: CGRect(x: 10, y: 0, width: mapWidth, height: 60))   
           searchBar.delegate = self self.view.addSubview(searchBar)    
           mapView.delegate = self mapView.mapType = .hybrid   
      }  
 }  

2. Map delegate
The MKMapView object requires that the view controller conforms to MKMapViewDelegate. In this example, I will only implement the mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) method, used for adding annotations to the map. In addition, the annotation will show a small + button; I can, for example, use it for adding the placemark to a placemark repository. I will talk about it later. For the moment, I just keep an empty implementation for savedPin action.

 extension MapViewController:MKMapViewDelegate{   
      func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?{   
           guard !(annotation is MKUserLocation) else { return nil }   
           let reuseId = "pin"   
           var pinView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseId) as? MKPinAnnotationView   
           if pinView == nil {    
                pinView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: reuseId)   
           }   
           pinView?.pinTintColor = UIColor.orange   
           pinView?.canShowCallout = true   
           let smallSquare = CGSize(width: 30, height: 30)   
           let button = UIButton(frame: CGRect(origin: CGPoint.zero, size: smallSquare))    
           button.setBackgroundImage(UIImage(systemName: "plus.square"), for: .normal)   
           button.addTarget(self, action: #selector(savedPin), for: .touchUpInside)    
           pinView?.leftCalloutAccessoryView = button    
           return pinView   
      }   
      @objc func savedPin(){    
      }  
 }  

3. SearchBar delegate
The Search bar needs the controller to conform with UISearchBarDelegate. I only implement the searchBarSearchButtonClicked(_ searchBar: UISearchBar) method; this is executed when the user presses Enter after typing a text in the search bar. The search function uses MKLocalSearch for performing a search using the apple map kit and returns a list of map items. Subsequently, I am taking the first one from the list and add a pin to the map containing the placemark embedded in this map item. addPin and addAnnotation methods are just helpers for creating an annotation from a placemark and adding it to the map. Finally, showAlert is used to display an alert if the search did not lead to any results.

 extension MapViewController:UISearchBarDelegate{   
      func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {   
           if self.mapView.annotations.count != 0 {    
                annotation = self.mapView.annotations[0]    
                self.mapView.removeAnnotation(annotation)   
           }    
           localSearchRequest = MKLocalSearch.Request()   
           localSearchRequest.naturalLanguageQuery = searchBar.text   
           localSearch = MKLocalSearch(request: localSearchRequest)   
           localSearch.start { (localSearchResponse, error) -> Void in    
                if localSearchResponse == nil {    
                     return self.showAlert()   
                }     
                guard let mapItem = localSearchResponse?.mapItems.first else {  return self.showAlert()  }     
                let placemark = mapItem.placemark     
                self.addPin( placemark: placemark)    
                self.selectedPin = placemark   
           }   
      }   
      func showAlert(){   
           let alert = UIAlertController(title: nil, message:"Place not found", preferredStyle: .alert)   
           alert.addAction(UIAlertAction(title: "Try again", style: .default) { _ in })   
           self.present(alert, animated: true){}   
      }   
      func addPin(placemark: MKPlacemark){   
           let annotation = MKPointAnnotation()   
           annotation.coordinate = placemark.coordinate   
           annotation.title = placemark.name    
           if let city = placemark.locality,  let state = placemark.administrativeArea {    
                annotation.subtitle = "\(city) \(state)"   
           }    
           addAnnotation(annotation: annotation)   
      }   
      func addAnnotation( annotation:MKPointAnnotation ){   
           mapView.addAnnotation(annotation)   
           let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)   
           let region = MKCoordinateRegion(center: annotation.coordinate, span: span)   
           mapView.setRegion(region, animated: true)   
      }  
 }  
4. interact with outside world
Obviously, I cannot contain the functionality online inside this controller. The views that will wrap and later embed this controller need to be triggered when various actions are performed inside the controller. Therefore, I am implementing a delegate protocol for interacting with my MapViewController:

 protocol MapViewDelegate{   
      func saveLocation(placemark: MKPlacemark)  
 }  

subsequently, add a delegate variable in my Controller:

 var delegate:MapViewDelegate!  

and finally , implement the savedPin() action method to use this delegate:

 @objc func savedPin(){   
      guard let delegate = delegate, let placemark = selectedPin else { return}   
      delegate.saveLocation(placemark: placemark)  
 }  

5. Final implementation of the controller

 import UIKit  
 import MapKit  
 protocol MapViewDelegate{    
    func saveLocation(placemark: MKPlacemark)   
  }   
  class MapViewController: UIViewController {    
       var delegate:MapViewDelegate!  
    var mapView: MKMapView!    
    fileprivate var searchBar: UISearchBar!    
    fileprivate var localSearchRequest: MKLocalSearch.Request!    
    fileprivate var localSearch: MKLocalSearch!    
    fileprivate var localSearchResponse: MKLocalSearch.Response!    
    fileprivate var annotation: MKAnnotation!    
    fileprivate var isCurrentLocation: Bool = false    
    var selectedPin: MKPlacemark?    
    override func viewDidLoad() {    
       super.viewDidLoad()    
       mapView = MKMapView()    
       let leftMargin:CGFloat = 10       
       let topMargin:CGFloat = 60       
       let mapWidth:CGFloat = view.frame.size.width-20       
       let mapHeight:CGFloat = view.frame.size.height - 100         
       mapView.frame = CGRect(x: leftMargin, y: topMargin, width: mapWidth, height: mapHeight)    
       mapView.isZoomEnabled = true       
       mapView.isScrollEnabled = true    
       self.view.addSubview(mapView)    
       searchBar = UISearchBar(frame: CGRect(x: 10, y: 0, width: mapWidth, height: 60))    
       searchBar.delegate = self self.view.addSubview(searchBar)    
       mapView.delegate = self mapView.mapType = .hybrid    
    }   
  }   
  extension MapViewController:MKMapViewDelegate{    
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?{    
       guard !(annotation is MKUserLocation) else { return nil }    
       let reuseId = "pin"    
       var pinView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseId) as? MKPinAnnotationView    
       if pinView == nil {    
         pinView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: reuseId)    
       }    
       pinView?.pinTintColor = UIColor.orange    
       pinView?.canShowCallout = true    
       let smallSquare = CGSize(width: 30, height: 30)    
       let button = UIButton(frame: CGRect(origin: CGPoint.zero, size: smallSquare))    
       button.setBackgroundImage(UIImage(systemName: "plus.square"), for: .normal)    
       button.addTarget(self, action: #selector(savedPin), for: .touchUpInside)    
       pinView?.leftCalloutAccessoryView = button    
       return pinView    
    }   
    @objc func savedPin(){    
           guard let delegate = delegate, let placemark = selectedPin else { return}    
           delegate.saveLocation(placemark: placemark)   
      }    
  }   
 extension MapViewController:UISearchBarDelegate{    
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {    
       if self.mapView.annotations.count != 0 {    
         annotation = self.mapView.annotations[0]    
         self.mapView.removeAnnotation(annotation)    
       }    
       localSearchRequest = MKLocalSearch.Request()    
       localSearchRequest.naturalLanguageQuery = searchBar.text    
       localSearch = MKLocalSearch(request: localSearchRequest)    
       localSearch.start { (localSearchResponse, error) -> Void in    
         if localSearchResponse == nil {    
            return self.showAlert()    
         }     
         guard let mapItem = localSearchResponse?.mapItems.first else { return self.showAlert() }     
         let placemark = mapItem.placemark     
         self.addPin( placemark: placemark)    
         self.selectedPin = placemark    
       }    
    }    
    func showAlert(){    
       let alert = UIAlertController(title: nil, message:"Place not found", preferredStyle: .alert)    
       alert.addAction(UIAlertAction(title: "Try again", style: .default) { _ in })    
       self.present(alert, animated: true){}    
    }    
    func addPin(placemark: MKPlacemark){    
       let annotation = MKPointAnnotation()    
       annotation.coordinate = placemark.coordinate    
       annotation.title = placemark.name    
       if let city = placemark.locality, let state = placemark.administrativeArea {    
         annotation.subtitle = "\(city) \(state)"    
       }    
       addAnnotation(annotation: annotation)    
    }    
    func addAnnotation( annotation:MKPointAnnotation ){    
       mapView.addAnnotation(annotation)    
       let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)    
       let region = MKCoordinateRegion(center: annotation.coordinate, span: span)    
       mapView.setRegion(region, animated: true)    
    }   
  }      

6. wrap the controller
Now let’s wrap the previously created view controller into a UIViewControllerRepresentable to be used later in SwiftUI views. This is a standard implementation, following the apple example. A coordinator class conforming to MapViewDelegate protocol is implementing the saveLocation method (just prints for now) . makeUIViewController is creating the controller from above and return it. After that, updateUIViewController is attaching the Coordinator as a delegate to the MapViewController.

 struct MapSearchView: UIViewControllerRepresentable {   
      class Coordinator: NSObject, MapViewDelegate {   
           func saveLocation(placemark: MKPlacemark) {    
                print("add placemark" )   
           }    
      }    
      func makeCoordinator() -> Coordinator {   
           return Coordinator()    
      }    
      func makeUIViewController(context: UIViewControllerRepresentableContext<MapSearchView>) -> MapViewController {      
           let mapController = MapViewController()     return mapController    
      }    
      func updateUIViewController(_ uiViewController: MapViewController, context: UIViewControllerRepresentableContext<MapSearchView>) {    
           uiViewController.delegate = context.coordinator    
      }       
 }  

7. Usage
Now lets use this view in a sample SwiftUI application:

 struct ContentView: View {    
      var body: some View {   
           MapSearchView()    
      }  
 }  

Here is how the controls are looking like in my sample application:


I hope you enjoyed this tutorial and start including MapKit in your SwiftUI applications. You can read more SwiftUI posts here.

Comments

Popular posts from this blog

SwiftUI: Custom navigation

SwiftUI UIKit – part2: embed SwiftUI in UIViewController