Implementing zoom controls for MapKit on iOS
Buttons are usually much more accessible than gestures, so adding zoom buttons can be a great feature to add to any map. Unfortionaly, Apples MapKit does not support showsZoomControl for iOS, so we have to implement the controls ourselves.
Buttons
We start by creating two UIButtons. I'll use icons from Apples SF Symbols (plus.magnifyingglass and minus.magnifyingglass).
private lazy var increaseZoomButton: UIButton = {
let button = UIButton(withAutoLayout: true)
button.backgroundColor = R.color.color1()
button.setImage(UIImage(systemName: "plus.magnifyingglass"), for: .normal)
button.addTarget(self, action: #selector(increaseZoomLevel), for: .touchUpInside)
button.layer.cornerRadius = 4
button.layer.borderColor = R.color.color2()?.cgColor
button.layer.borderWidth = 1
return button
}()
private lazy var decreaseZoomButton: UIButton = {
let button = UIButton(withAutoLayout: true)
button.backgroundColor = R.color.color1()
button.setImage(UIImage(systemName: "minus.magnifyingglass"), for: .normal)
button.addTarget(self, action: #selector(decreaseZoomLevel), for: .touchUpInside)
button.layer.cornerRadius = 4
button.layer.borderColor = R.color.color2()?.cgColor
button.layer.borderWidth = 1
return button
}()
When pressed, the buttons call the functions increaseZoomLevel
and decreaseZoomLevel
. We will create those functions later.
Positioning the buttons
I use autoLayout to position the buttons after adding them to the view.
view.addSubview(increaseZoomButton)
view.addSubview(decreaseZoomButton)
NSLayoutConstraint.activate([
increaseZoomButton.topAnchor.constraint(equalTo: userLocation.bottomAnchor, constant: .normalSpacing(multiplier: 2)),
increaseZoomButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -.normalSpacing(multiplier: 2)),
increaseZoomButton.heightAnchor.constraint(equalToConstant: 44),
increaseZoomButton.widthAnchor.constraint(equalToConstant: 44),
decreaseZoomButton.topAnchor.constraint(equalTo: increaseZoomButton.bottomAnchor, constant: .normalSpacing(multiplier: 2)),
decreaseZoomButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -.normalSpacing(multiplier: 2)),
decreaseZoomButton.heightAnchor.constraint(equalToConstant: 44),
decreaseZoomButton.widthAnchor.constraint(equalToConstant: 44),
])
Handling button presses
To handle the button presses, I have created two functions increaseZoomLevel
and decreaseZoomLevel
, which both call the zoomClickHandler
function with their respective direction.
@objc
func increaseZoomLevel() {
zoomClickHandler(direction: .increase)
}
@objc
func decreaseZoomLevel() {
zoomClickHandler(direction: .decrease)
}
zoomClickHandler
The zoomClickHandler
ensures that the user is sharing their location. It then calls the updateZoomLevel
function, giving the user's current location and the requested zoom direction as arguments.
private func zoomClickHandler(direction: Direction) {
if viewModel.location.isAuthorized {
updateZoomLevel(location: mapView.userLocation.coordinate, direction: direction)
} else {
firstly {
viewModel.location.requestAuthorization()
}.then {
self.viewModel.location.requestLocation()
}.done { location in
self.zoomTo(location: location.coordinate)
}.catch { _ in
self.coordinator.perform(action: .missingLocationSettings, in: self)
}
}
}
Updating the zoom level
First, updateZoomLevel
checks that the user's current location is valid before proceeding. It then uses a helper function to get the mapView's current location, which is then used to update the zoom level in the given direction.
We then create a new viewRegion with the updated zoom level and use mapView.setRegion
to apply the changes.
private func updateZoomLevel(location: CLLocationCoordinate2D, direction: Direction) {
guard CLLocationCoordinate2DIsValid(location),
location.latitude != 0 && location.longitude != 0 else {
return
}
let currentLocation = getCurrentMapLocation(mapView: mapView)
let updatedLocation = getUpdatedMapLocation(currentLocation: currentLocation, direction: direction)
let viewRegion = MKCoordinateRegion(center: mapView.region.center, latitudinalMeters: updatedLocation.metersInLatitude, longitudinalMeters: updatedLocation.metersInLongitude)
mapView.setRegion(viewRegion, animated: true)
}
Helper functions
To keep the controller clean, I have extracted a lot of the map logic to its own helper. This includes our Direction enum, a MapCoordinates struct, and the getCurrentMapLocation and getUpdatedMapLocation functions.
import Foundation
import MapKit
enum Direction {
case increase
case decrease
var bool: Bool {
switch self {
case .increase:
return true
default:
return false
}
}
}
struct MapCoordinates {
var metersInLatitude: Double
var metersInLongitude: Double
}
func getCurrentMapLocation(mapView: MKMapView) -> MapCoordinates {
let span = mapView.region.span
let center = mapView.region.center
let loc1 = CLLocation(latitude: center.latitude - span.latitudeDelta * 0.5, longitude: center.longitude)
let loc2 = CLLocation(latitude: center.latitude + span.latitudeDelta * 0.5, longitude: center.longitude)
let loc3 = CLLocation(latitude: center.latitude, longitude: center.longitude - span.longitudeDelta * 0.5)
let loc4 = CLLocation(latitude: center.latitude, longitude: center.longitude + span.longitudeDelta * 0.5)
return MapCoordinates(
metersInLatitude: loc1.distance(from: loc2),
metersInLongitude: loc3.distance(from: loc4)
)
}
func getUpdatedMapLocation(currentLocation: MapCoordinates, direction: Direction) -> MapCoordinates {
let updatedLatitude = currentLocation.metersInLatitude + (direction.bool ? (-currentLocation.metersInLatitude * 0.6) : (currentLocation.metersInLatitude * 2))
let updatedLongitude = currentLocation.metersInLongitude + (direction.bool ? (-currentLocation.metersInLongitude * 0.6) : (currentLocation.metersInLongitude * 2))
return MapCoordinates(
metersInLatitude: updatedLatitude,
metersInLongitude: updatedLongitude
)
}