isak.me - a blog by isak solheim

Bi-weekly updates about things that interest me, things I have built and things I have learned.

Implementing zoom controls for MapKit on iOS

map with zoom buttons

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