SwiftUI UIKit – part2: embed SwiftUI in UIViewController

In a previous post, I was showing you how to embed a UIViewController into a SwiftUI view. For this example, I used a map view, touching some of the basic functionality of MapKit as well. In this article, I will try the opposite exercise: embed SwiftUI into a ViewController and call it inside other View Controller.
Apple made it very simple to combine SwiftUI and UiKit. In this way, a developer can use existing UiKit functionality, controls and view controllers in SwiftUI views. And vice versa, your shiny brand new SwiftUI view can be called from UIView controller. If you remember the previous post, I was showing you an example of a search bar and a map view. Whenever a search was performed, if at least one relevant item was found, a pin was added to the list. But what if there are multiple results? Well, in this case, I want to show a view displaying all those results, to let my user select which one he or she actually intended to search for. And the one that is selected will be added on the map as a pin. Lets start with this exercise!
1. AlertAction
Let’s represent the information that will be shown in the selection view in a simple AlertAction struct; it will have a title, a sub-title, and an action, executed if tapped. I am making this struct Identifiable, so that it can be used by ForEach, later on.

 struct AlertAction: Identifiable{   
      var id: Int   
      var title: String   
      var subTitle: String   
      var action: ()->Void  
 }  

2. Show a list of AlertActions
I create a simple view, showing a list of alerts that are passed in as parameter. I am using the GeometryReader to get the height of the view and distribute it equally to the items in the alert list. After the user selects an item and action is executed, I am calling a dismiss callback, that is used to dismiss the alert.

 struct ResultsAlert: View {   
      var actions: [AlertAction]   
      var dismiss: ()->Void     
      var body: some View {   
           GeometryReader{ geometry in    
                VStack(spacing: 0){    
                     ForEach( self.actions ){ action in     
                          ZStack{     
                               Rectangle()      
                                    .fill( Color.clear)      
                                    .frame( height: geometry.size.height / CGFloat(self.actions.count))     
                                    VStack{      
                                         Text(action.title).font(.body)      
                                         Text(action.subTitle).font(.caption)     
                                    }     
                               }.onTapGesture{     
                                    action.action()     
                                    self.dismiss()     
                               }    
                     }    
                }.background(RoundedRectangle( cornerRadius: 10)  
                .fill(LinearGradient(gradient: Gradient(colors: [Color.gray, Color.white]), startPoint: .topLeading, endPoint: .bottomTrailing)))   
           }    
      }  
 }  

3. Wrap the View in an UIViewController
Now, here is how to wrap the View in an UIViewController. I am not using a storyboard, constraints or anything in that area; I am just simply creating a view, assigning it a frame having enough size to accommodate the whole content of ResultsAlert. After that, I use an UIHostingController to embed the SwiftUI view and I am adding the underlying view of this controller as sub-view for the above-created view. Also the dismiss method is just dismissing the controller. And that’s it, now you have an UIViewController encapsulating the SwiftUI view

 class ResultsAlertController: UIViewController {   
      var actions: [AlertAction]   
      init( actions: [AlertAction] ){   
           self.actions = actions   
           super.init(nibName: nil, bundle:nil)   
      }   
      required init?(coder: NSCoder) {   
           fatalError("init(coder:) has not been implemented")   
      }   
      override func viewDidLoad() {      
           super.viewDidLoad()    
           let view = UIView()   
           var height = CGFloat( 60 * actions.count )   
           height = min( height, 600)   
           view.frame = CGRect(x: 0, y:0, width: 300, height: height)   
           view.backgroundColor = UIColor.clear   
           self.view = view    
           let controller = UIHostingController(rootView: ResultsAlert(actions: actions, dismiss: dismiss))    
           controller.view.frame = CGRect(x: 10, y:UIScreen.main.bounds.height - 520, width: UIScreen.main.bounds.width - 20, height: height)   
           controller.view.backgroundColor = UIColor.clear    
           self.view.addSubview(controller.view)    
      }   
      func dismiss(){   
           self.dismiss(animated: true, completion: nil)   
      }  
 }  

Lets make some small changes to the MapViewController. Instead of just adding the pin for the first results in the list of search results, I am calling a resultsAlert method:

 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 )        
           }      
      }  
 }  

5. resultsAlert
Therefore, here is the implementation of resultsAlert. Sometimes, the same item appears twice in the list of results, and this may be be confusing for the user. Therefore, I am presenting a unique list of items.

 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)   
 }  

6. Entire MapViewController
In the end, the entire MapViewController, with all the features developed in part 1 of this post included:
 import UIKit  
 import MapKit  
 protocol MapViewDelegate{   
      func saveLocation(placemark: MKPlacemark)  
 }  
 class MapViewController: UIViewController {   
      var delegate:MapViewDelegate!   
      var mapView: MKMapView!    
      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 { (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)   
      }   
      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)   
      }  
 }  
Also a screenshot, showing how this alert view is looking like:










Comments

Popular posts from this blog

SwiftUI UIKit – part3: push data to UIViewController

SwiftUI - interacting with UIKit - part1: map view