Getting actual rotation and zoom level for MapKit MKMapView

For one of my apps I’ve been creating customized map based on MapKit framework. I had to implement additional functionality and restrictions, which were dependent on knowing actual map rotation and zoom.

There are two things you should know about MapKit and those properties:

  • MapKit does not support “zoom” as property at all;
  • MapKit provides “rotation” property (via MKMapView.camera.heading), but it is updated for delegates only after any transition has ended.

Getting MKMapView zoom

I’ve investigated internet to find a solution for zoom property. Here is a description of great and simple idea I’ve found.

Let’s see how it works.

What does “zoom” mean actually?

MapKit renders map as a set of square tiles. The world itself rendered by MapKit is processed as a Mercator projection and it is square too. In the smallest rendered size it consists of single tile. That’s for zoom z = 0. For the next zoom level (z = 1) it has side of 2 tiles. For the next zoom level (z = 2) the side is 4 tiles long, etc. In brief, length of the “world’s” side is 2^z tiles, where z is current zoom level.

So, you can see now, that zoom level and world size in tiles (in pixels too) are mutually dependent values.

How to get actual zoom value?

MKMapView has a property .region, which means a portion of map, currently “displayed by the map view” (I’ve put the description in quotes to get your attention on purpose: you will see the problem of this statement below). .region itself has property .span — a region interpretation in degrees: deltaLatitude and deltaLongitude. Using these values we can determine a portion of map, currently visible. Comparing it to the size of the whole world we can determine scale and zoom values.

deltaLatitude is useless itself, because for Mercator projection latitude changes nonlinearly. On the opposite, longitude has linear dependency on its projection for any latitude. It means that if displayed region of the map in points equals to screenWidth, then this equation is true:

screenWidth / deltaLongitude = 
worldSizeInPoints / worldSizeInLatitudeDegrees =
worldSizeInTiles * tileSizeInPoints / 360 =
2^z * tileSizeInPoints / 360

In brief:

screenWidth / deltaLongitude = 2^z * tileSizeInPoints / 360

and:

z = log2(360 * screenWidth / (deltaLongitude * tileSizeInPoints))

Finally:

z = log2(360 * mapView.frame.size.width / (mapView.region.span.longitudeDelta * 128))

But… It doesn’t work when map is rotated. :)

Why?

Getting rotated map properties

What MapKit creators did forget to say about mapView.region is that it does not have the same value, as screen does. In reality it means the size of a rectangle, required to be rendered to cover all the screen after rotation.

This is how it looks like:

As you can see on the picture, this is what we get after map rotation:

  • screenWidth corresponds to the segment OC;
  • deltaLongitude corresponds to the segment OB.

To use the formula mentioned above we need to convert deltaLongitude into value deltaLongitudeStraight, as if the map did not rotate at all. Then, this proportion will be true:

OC / deltaLongitudeStraight = OB / deltaLongitude

and as OC = screenWidth, then:

deltaLongitudeStraight = deltaLongitude * screenWidth / OB

As we know map rotation, we can write down these equations:

OB = OA * cosα
OA = screenWidth + AC = screenWidth + screenHeight * tanα
OB = (screenWidth + screenHeight * tanα) * cosα
OB = screenWidth * cosα + screenHeight * sinα

The whole formula:

deltaLongitudeStraight = deltaLongitude * screenWidth / (screenWidth * cosα + screenHeight * sinα)

Now, we can use this deltaLogitudeStraight value in the formula we had previously. And finally… it works! :)

public func getZoom() -> Double {
// function returns current zoom of the map
var angleCamera = self.rotation
if angleCamera > 270 {
angleCamera = 360 — angleCamera
} else if angleCamera > 90 {
angleCamera = fabs(angleCamera — 180)
}
let angleRad = M_PI * angleCamera / 180 // map rotation in radians
let width = Double(self.frame.size.width)
let height = Double(self.frame.size.height)
let heightOffset : Double = 20
// the offset (status bar height) which is taken by MapKit
// into consideration to calculate visible area height.
// calculating Longitude span corresponding to normal
// (non-rotated) width
let spanStraight = width * self.region.span.longitudeDelta / (width * cos(angleRad) + (height — heightOffset) * sin(angleRad))
return log2(360 * ((width / 128) / spanStraight))
}

The last challenge left is to have true map rotation all the time. As you can see in the code above variable self.rotation is highlighted with bold font. This is because it is not defined by MapKit itself. It is defined by me.

Getting rotation

Find the part of the map, which reliably stores actual map rotation value

I have spent much time researching MKMapView instance during runtime, exploring its subviews to find its actual structure.

MKMapView is a kind of UIView container, which contains a kind of canvas inside, where the map is rendered as being straight and after that — transformed. This canvas is an instance of class named MKScrollContainerView. To apply rotation to map it is simply processed by changing its .transform property. Rotation matrix, used for that purpose, is described in Apple’s documentation. It looks this way:

cosA  sinA 0
-sinA cosA 0
0     0    1

So, to get true rotation of the map we can just find the canvas, get its .transform property and process its values. Though the idea is not that obvious generally, the code is pretty simple.

This is how I get the instance of canvas:

public class MyMap : MKMapView {
  private var mapContainerView : UIView?
  override public init(frame: CGRect) {

self.mapContainerView = self.findViewOfType(“MKScrollContainerView”, inView: self)

}
  private func findViewOfType(_ viewType: String, inView view: UIView) -> UIView? {
// function scans subviews recursively and returns
// reference to the found one of a type
if view.subviews.count > 0 {
for v in view.subviews {
let valueDescription = v.description
let keywords = viewType
if valueDescription.range(of: keywords) != nil {
return v
}
if let inSubviews = self.findViewOfType(viewType, inView: v) {
return inSubviews
}
}
return nil
} else {
return nil
}
}
}

Finally, to have continuous update of rotation property I’ve created a loop, which checks rotation change, saves it and sends to mapView listener, if there is any.

@objc public protocol MyMapListener {
@objc optional func mapView(_ mapView: MyMap, rotationDidChange rotation: Double)
// message is sent when map rotation is changed
}
@objc private func trackChanges() {
// function detects map changes and processes it
if let rotation = self.getRotation() {
if rotation != self.rotation {
self.rotation = rotation
self.listener?.mapView?(self, rotationDidChange: rotation)
}
}
}
private func startTrackingChanges() {
// function starts tracking map changes
if self.changesTimer == nil {
self.changesTimer = Timer(timeInterval: 0.1, target: self, selector: #selector(MyMap.trackChanges), userInfo: nil, repeats: true)
RunLoop.current.add(self.changesTimer!, forMode: RunLoopMode.commonModes)
}
}
private func stopTrackingChanges() {
// function stops tracking map changes
if self.changesTimer != nil {
self.changesTimer!.invalidate()
self.changesTimer = nil
}
}

That’s all.

You can download full sample project in my GitHub repository.