Select Page

When developing iOS apps that require maps, you often face the choice between Apple Maps and Google Maps. During development, you might also want to experiment with both to determine which one better suits your needs.

To address this challenge, you can establish a protocol (abstraction) that any map framework can conform to. Rather than interacting with specific map implementations, any class requiring map functionality interacts with this protocol, introducing a layer of flexibility to your code.

There are several reasons why using a protocol instead of concrete map implementations is beneficial:

  1. Postpone Decision Making: Defer the decision on which map to use, allowing you to start with a fake map view if needed.
  2. Easy Map Switching: Easily switch between different map providers without altering the client code significantly.
  3. Cleaner View Controllers: Enhance the cleanliness of view controllers and classes using the map, as they only interact with the protocol.
  4. Tailored Protocol Design: Create the protocol based on the specific requirements of your app, ensuring precise functionality.
  5. Improved testability

Example App

Imagine an app that simply displays the user’s location on the map. The MapViewWrapper protocol for this scenario might include these functions:

  1. Set center coordinate on the map
  2. Add annotation to the map, and replace annotation with the same identifier

Additional functions are added to the protocol only if the app requires them, resulting in cleaner and more efficient code. For function with different responsibilities we can also consider creating separate protocols; for instance, MapViewAction protocol could handle map gestures like taps.

public protocol MapViewWrapper{
    func view() -> UIView
    func setCenterCoordinate(coordinate: CLLocationCoordinate2D, zoomLevel: Double, animated: Bool)
    func addAnnotation(identifier: String, coordinate: CLLocationCoordinate2D)
}

The final code design looks like the dependency diagram below:

Map View Implementation

Implementation for both Apple Maps and Google Maps adheres to the MapViewWrapper protocol. Although the implementation details differ, the functions defined in the protocol ensure similar outcomes across various map frameworks.

Apple Maps implementation:

import Foundation
import MapKit

public class AppleMapView: MapViewWrapper{
    private let mapView = MKMapView(frame: .zero)
    
    public func view() -> UIView{
        return mapView
    }
    
    public func setCenterCoordinate(coordinate: CLLocationCoordinate2D, zoomLevel: CGFloat, animated: Bool) {
        mapView.setCenter(coordinate, animated: animated)
        let zoomDistance = CLLocationDistance(exactly: 20000/zoomLevel)!
        let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: zoomDistance, longitudinalMeters: zoomDistance)
        mapView.setRegion(region, animated: animated)
    }
    
    public func addAnnotation(identifier: String, coordinate: CLLocationCoordinate2D) {
        if let annotation = (mapView.annotations as? [MapAnnotation])?.first(where: {$0.identifier == identifier}){
            annotation.coordinate = coordinate
            mapView.removeAnnotation(annotation)
            mapView.addAnnotation(annotation)
        }else{
            let annotation = MapAnnotation(identifier: identifier, coordinate: coordinate)
            mapView.addAnnotation(annotation)
        }
    }
}

class MapAnnotation: NSObject, MKAnnotation{
    var coordinate: CLLocationCoordinate2D
    let identifier: String
    
    init(identifier:String, coordinate: CLLocationCoordinate2D) {
        self.identifier = identifier
        self.coordinate = coordinate
    }
}

Google Maps implementation:

import Foundation
import GoogleMaps

public class GoogleMapView: NSObject, MapViewWrapper{
    private let mapView = GMSMapView(frame: CGRect.zero)
    private var annotations = [GMSMarker]()
    
    public func view() -> UIView{
        return mapView
    }
    
    public func setCenterCoordinate(coordinate: CLLocationCoordinate2D, zoomLevel: CGFloat, animated: Bool) {
        let camera = GMSCameraPosition(latitude: coordinate.latitude, longitude: coordinate.longitude, zoom: Float(zoomLevel))
        if animated{
            mapView.animate(to: camera)
        }else{
            mapView.camera = camera
        }
    }
    
    public func addAnnotation(identifier: String, coordinate: CLLocationCoordinate2D) {
        if let annotation = annotations.first(where: {($0.userData as? String) == identifier}){
            annotation.position = coordinate
        }else{
            let annotation = GMSMarker(position: coordinate)
            annotation.userData = identifier
            annotation.map = mapView
            annotations.append(annotation)
        }
    }
}

View Controller Implementation

In your view controller, you inject the MapViewWrapper dependency. This decouples the view controller from specific map implementations, which also result in a more modular and maintainable codebase.

import UIKit

public class LocationViewController: UIViewController{
    private let mapViewWrapper: MapViewWrapper
    private var mapView: UIView{
        return mapViewWrapper.view()
    }
    
    public init(mapViewWrapper: MapViewWrapper) {
        self.mapViewWrapper = mapViewWrapper
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    public override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
        showLocation(CLLocationCoordinate2D(latitude: 3.1386, longitude: 101.6044))
    }
    
    private func setupView(){
        mapView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(mapView)
        setupConstraints()
    }
    
    private func setupConstraints(){
        NSLayoutConstraint.activate([
            mapView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            mapView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            mapView.topAnchor.constraint(equalTo: self.view.topAnchor),
            mapView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0),
        ])
    }
    
    public func showLocation(_ coordinate: CLLocationCoordinate2D){
        mapViewWrapper.setCenterCoordinate(
            coordinate: coordinate,
            zoomLevel: 15,
            animated: true)
        mapViewWrapper.addAnnotation(identifier: "location", coordinate: coordinate)

    }
}

Map View dependency injection

We inject the map view implementation when initializing the view controller. In this case, we use SceneDelegate to setup our view controller and inject the map view implementation. We can easily switch between different map providers with minimal code changes.

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        
        let mapView = AppleMapView()
//        let mapView = GoogleMapView() //Uncomment to use google map view (You need to setup key in AppDelegate!)
//        let mapView = FakeMapView() //Uncomment to use fake map view

        window!.rootViewController = LocationViewController(mapViewWrapper: mapView)
        window!.makeKeyAndVisible()
    }

}

Conclusion

Utilizing the MapViewWrapper protocol results in cleaner client classes, improved testability, and flexibility in selecting map providers. Check out the final example project on GitHub. I hope you find this article insightful and enjoyable!