SwiftUI UIKit – part3: push data to UIViewController

This is already part 3 of a small series of articles about the SwiftUI and UIKit interaction. You can read the part1 and part 2 on my blog. This article is about pushing data and triggering actions from the SwiftUI to the UIViewController.
The examples presented by Apple about interfacing with UIKit, shows a coordinator conforming to a protocol and having it as a delegate for a view or a view controller. However, I was not able to find any information on how to push back data into the View Controller, as result of actions performed by the user in the SwiftUI view. Therefore, in this article, I will try to show exactly this; I will be covering also the very cool feature of adding a custom overlay to a map view. So let’s dive into the sample.
1. Custom overlay
This article will not really get into the details about creating the overlay. I am pretty sure there are more interesting examples on the internet, however for illustration purposes, I will create a circle, with the color passed from outside; I will display it over the map view, using a custom circle overlay. It is not absolutely necessary that the shape class is inheriting from MKPolyLine. I didn’t want to spend time implementing the MKOverlay protocol; furthermore, I wanted to subclass my render class from MKOverlayPathRenderer to benefit from some cool functionality provided by it; MKMapPoint to CGPoint conversion is one of them.

 import Foundation  
 import MapKit  
 class CustomCircle: MKPolyline{   
      lazy var center: MKMapPoint = { self.points()[0] }()   
      var color: UIColor = UIColor.red   
      convenience init(coordinate:CLLocationCoordinate2D, color: UIColor) {   
           self.init(coordinates: [coordinate], count: 1)   
           self.color = color   
      }  
 }  
 class CustomCircleRenderer: MKOverlayPathRenderer {   
      /// The shape to render   
      var shape: CustomCircle   
      init(shape: CustomCircle) {   
           self.shape = shape    
           super.init(overlay: shape)   
      }   
      //MARK: Override methods   
      override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {   
           /*  Set path width relative to map zoom scale  */   
           let baseWidth: CGFloat = self.lineWidth / zoomScale    
           context.setLineWidth(baseWidth * 2)   
           context.setLineJoin(CGLineJoin.round)   
           context.setLineCap(CGLineCap.round)    
           let circleCenter = self.point(for: shape.center)   
           let length = CGFloat(5000)    
           context.setStrokeColor(shape.color.cgColor)   
           context.strokeEllipse(in: CGRect(x: circleCenter.x-length, y: circleCenter.y-length, width: 2*length, height: 2*length))   
           super.draw(mapRect, zoomScale: zoomScale, in: context)   
      }  
 }  

2. New callout to the pin annotation
I am adding a new callout button to the annotation pin, fin order to display the overlay. It will be shown on the right-hand side of the annotation view.
 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 lbutton = UIButton(frame: CGRect(origin: CGPoint.zero, size: smallSquare))    
      lbutton.setBackgroundImage(UIImage(systemName: "plus.square"), for: .normal)   
      lbutton.addTarget(self, action: #selector(savedPin), for: .touchUpInside)    
      pinView?.leftCalloutAccessoryView = lbutton    
      let rbutton = UIButton(frame: CGRect(origin: CGPoint.zero, size: smallSquare))   
      rbutton.setBackgroundImage(UIImage(systemName: "c.circle"), for: .normal)   
      rbutton.addTarget(self, action: #selector(drawCircle), for: .touchUpInside)    
      pinView?.rightCalloutAccessoryView = rbutton    
      return pinView   
 }  

3. drawCircle
The drawCircle function, called by the callout button is simply cleaning the existing overlays and adding a custom circle

 @objc func drawCircle(){   
      guard let pin = selectedPin else { return}    
      cleanOverlays()    
      var overlays = [MKOverlay]()   
      let circle = CustomCircle(coordinate: pin.coordinate, color:color)   
      overlays.append(circle)   
      mapView.addOverlays(overlays)   
 }   
 func cleanOverlays(){   
      let oldOverlays = mapView.overlays   
      mapView.removeOverlays(oldOverlays)   
 }  

4. use the custom rendered
In order to use the custom renderer, I am implementing the mapView rendererFor overlay from MKMapViewDelegate protocol. In this function, if the overlay is my own type, I am instantiating my renderer. Otherwise, I simply use the default renderer.
 func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {   
      if let mCircle = overlay as? CustomCircle{    
           let renderer = CustomCircleRenderer(shape: mCircle)        
           renderer.lineWidth = 4.0        
           return renderer      
      }      
      return MKOverlayRenderer(overlay: overlay)    
 }  
5. deselect the annotation after tapping callout
I want to dismiss the pin annotation view after tapping a callout button; therefore I am implementing yet another method from MKMapViewDelegate protocol:

 func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl){   
      view.isSelected = false  
 }  

6. extend the MapViewDelegate
Now, I extend the MapViewDelegate protocol with a new method. It is used to register a callback for passing in a color from the outside SwiftUI view, color that will be used to draw the overlay with.

 protocol MapViewDelegate{   
      func saveLocation(placemark: MKPlacemark)   
      func registerColorCallback(callback: @escaping (UIColor)->Void )  
 }  

7. setting the color
I add a color member in my MapViewController and a function for setting it. After setting the color, I am also calling drawCircle to draw the circle overlay, but only if a pin is selected.

 func addColor( color: UIColor){   
      self.color = color drawCircle()  
 }  

8. register the color callback
I register the color callback in viewDidAppear:

 override func viewDidAppear(_ animated: Bool){   
      super.viewDidAppear(animated)    
      self.delegate.registerColorCallback(callback: addColor)  
 }  

9. the entire listing of MapViewController

 import UIKit  
 import MapKit  
 protocol MapViewDelegate{    
   func saveLocation(placemark: MKPlacemark)    
   func registerColorCallback(callback: @escaping (UIColor)->Void )   
 }   
  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?   
      var color = UIColor.blue   
      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   
      }   
      override func viewDidAppear(_ animated: Bool){   
           super.viewDidAppear(animated)    
           self.delegate.registerColorCallback(callback: addColor)   
      }   
      func addColor( color: UIColor){   
           self.color = color drawCircle()   
      }  
 }  
  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 lbutton = UIButton(frame: CGRect(origin: CGPoint.zero, size: smallSquare))    
           lbutton.setBackgroundImage(UIImage(systemName: "plus.square"), for: .normal)   
           lbutton.addTarget(self, action: #selector(savedPin), for: .touchUpInside)    
           pinView?.leftCalloutAccessoryView = lbutton    
           let rbutton = UIButton(frame: CGRect(origin: CGPoint.zero, size: smallSquare))   
           rbutton.setBackgroundImage(UIImage(systemName: "c.circle"), for: .normal)   
           rbutton.addTarget(self, action: #selector(drawCircle), for: .touchUpInside)    
           pinView?.rightCalloutAccessoryView = rbutton    
           return pinView   
      }   
      func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {   
           if let mCircle = overlay as? CustomCircle{    
                let renderer = CustomCircleRenderer(shape: mCircle)        
                renderer.lineWidth = 4.0        
                return renderer      
           }      
           return MKOverlayRenderer(overlay: overlay)    
      }   
      func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl){   
           view.isSelected = false   
      }   
      @objc func savedPin(){   
           guard let delegate = delegate, let placemark = selectedPin else { return}   
           delegate.saveLocation(placemark: placemark)   
      }   
      @objc func drawCircle(){   
           guard let pin = selectedPin else { return}    
           cleanOverlays()    
           var overlays = [MKOverlay]()   
           let circle = CustomCircle(coordinate: pin.coordinate, color:color)   
           overlays.append(circle) mapView.addOverlays(overlays)   
      }   
      func cleanOverlays(){   
           let oldOverlays = mapView.overlays   
           mapView.removeOverlays(oldOverlays)   
      }  
 }  
 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 { (response, error) in        
                if let response = response {    
                     self.resultsAlert( mapItems: response.mapItems )        
                }      
           }   
      }   
      func resultsAlert(mapItems: [MKMapItem]){    
           var items: Set<String> = []   
           var actions: [AlertAction] = []    
           var idx: Int = 0 for mapItem in mapItems{    
                if items.contains(mapItem.name!){  continue  }   
                items.insert(mapItem.name!)     
                idx = idx + 1    
                let placemark = mapItem.placemark    
                actions.append(AlertAction(id: idx, title: mapItem.name!, subTitle: placemark.title!, action: {    
                     self.addPin( placemark: placemark)         
                }))   
           }    
           let customAlert = ResultsAlertController( actions:actions)      
           customAlert.providesPresentationContextTransitionStyle = true      
           customAlert.definesPresentationContext = true      
           customAlert.modalPresentationStyle = UIModalPresentationStyle.overCurrentContext      
           customAlert.modalTransitionStyle = UIModalTransitionStyle.crossDissolve      
           self.present(customAlert, animated: true, completion: nil)   
      }   
      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)   
           selectedPin = placemark   
      }   
      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)   
      }  
 }  

10. Bridging the SwiftUI view using the UIViewControllerRepresentable
Let’s recall the part 1 of the series, where I introduced you the UIViewControllerRepresentable struct; it was used to wrap the UIViewController. Its coordinator is conforming with MapViewDelegate protocol but at that time, there was only one function in the protocol: saveLocation. The implementation for it was just to print a message. Now, I want to allow the SwiftUI view to interact with the MapViewController; therefore the UIViewControllerRepresentable and its coordinator will act as a bridge. The Coordinator will conform with MapViewDelegate protocol and will just forward the calls to another MapViewDelegate object, that is injected into MapSearchView.

 struct MapSearchView: UIViewControllerRepresentable {   
      var delegate: MapViewDelegate   
      class Coordinator: NSObject, MapViewDelegate {    
           var delegate: MapViewDelegate    
           init(_ delegate: MapViewDelegate){    
                self.delegate = delegate   
           }    
           func registerColorCallback(callback: @escaping (UIColor) -> Void) {    
                delegate.registerColorCallback(callback:callback)   
           }    
           func saveLocation(placemark: MKPlacemark) {   
                delegate.saveLocation(placemark: placemark)   
           }   
      }   
      func makeCoordinator() -> Coordinator {    
           return Coordinator(delegate)    
      }    
      func makeUIViewController(context: UIViewControllerRepresentableContext<MapSearchView>) -> MapViewController {    
           let mapController = MapViewController()     
           return mapController    
      }    
      func updateUIViewController(_ uiViewController: MapViewController,  context: UIViewControllerRepresentableContext<MapSearchView>) {    
           uiViewController.delegate = context.coordinator    
      }  
 }  

11. Implement a SwiftUI view
I am implementing a new SwiftUI view to use the MapSearchView and also give user ability to pass the color into MapViewController. The view consists only of the MapSearchView and 4 buttons, colored in 4 different colors; tapping one of them, will pass its color through the MapSearchView. The View also implements the MapViewDelegate. I use a tiny MyCallback class, to get around immutability of the View struct and to be able to store the callback that came all the way from MapViewController. registerColorCallback is used for storing the callback.

 class MyCallback{   
      var colorCallback: ((UIColor) -> Void)!  
 }  
 struct ExampleView: View, MapViewDelegate {   
      var callback: MyCallback = MyCallback()     
      var body: some View {   
           VStack{    
                MapSearchView(delegate: self)    
                HStack{    
                     Rectangle().fill(Color.red).frame(width: 100, height:100).onTapGesture {     
                          self.callback.colorCallback(UIColor.red)    
                     }    
                     Rectangle().fill(Color.green).frame(width: 100, height:100).onTapGesture {     
                          self.callback.colorCallback(UIColor.green)    
                     }    
                     Rectangle().fill(Color.yellow).frame(width: 100, height:100).onTapGesture {     
                          self.callback.colorCallback(UIColor.yellow)    
                     }    
                     Rectangle().fill(Color.orange).frame(width: 100, height:100).onTapGesture {     
                          self.callback.colorCallback(UIColor.orange)    
                     }    
                }   
           }    
      }   
      func saveLocation(placemark: MKPlacemark) {   
           print("save location")   
      }   
      func registerColorCallback(callback: @escaping (UIColor) -> Void) {   
           self.callback.colorCallback = callback   
      }  
 }  
12. Finally the ContentView

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

Also the screenshot, showing our little example view:

Comments

Popular posts from this blog

SwiftUI: Custom navigation

SwiftUI: handling images in Dark theme

SwiftUI - interacting with UIKit - part1: map view