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:
- Postpone Decision Making: Defer the decision on which map to use, allowing you to start with a fake map view if needed.
- Easy Map Switching: Easily switch between different map providers without altering the client code significantly.
- Cleaner View Controllers: Enhance the cleanliness of view controllers and classes using the map, as they only interact with the protocol.
- Tailored Protocol Design: Create the protocol based on the specific requirements of your app, ensuring precise functionality.
- 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:
- Set center coordinate on the map
- 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
dependency. This decouples the view controller from specific map implementations, which also result in a more modular and maintainable codebase.MapViewWrapper
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!