3
votes

I have a MapKitView which has annotation which point in a certain direction. My problem is that when the user rotates the map using two fingers, or if the map rotates to track the user's heading, my symbol need to be rotated (which they don't as they are screen aligned).

I know I can rotate the symbols by the opposite of the map camera heading. I know I can be notified of changes in the user's heading to rotate the annotation in that case.

My problem is that I can't find a way of tracking the rotation of the map due to the user rotating it interactively.

I can track start and end of map region changes, but not the changes between the two. I tried using KVO with the camera's bearing but I'm not getting anything. I tried looking for notifications sent by the system, but again, nothing.

Anyone have any suggestions on how to reliably track the current map rotation?

4
Did you find a solution for this?RyuX51
Possible duplicate of MKMapView constantly monitor headingMogsdad

4 Answers

2
votes

Unfortunately MapKit itself provides no solution to track rotation changes. It provides events only in the beginning and at the end of rotation. And even more: it does not update heading value for camera, while rotating the map.

I had the same necessity and created own solution in Swift, which worked for me.

1. Subclass MKMapView to process its data

The most simple part:

class MyMap : MKMapView {

}

2. Find the object, which reliably has actual map rotation value

MKMapView is a kind of UIView container, which contains a kind of canvas inside, where the map is rendered and then transformed. I have researched MKMapView during runtime, exploring its subviews. The canvas has class name MKScrollContainerView. You have to control the instance, so you:

  1. add the object to class
  2. write the function to find that object inside MKMapView
  3. find the canvas and save it

The code:

class MyMap : MKMapView {

  var mapContainerView : UIView?

  init() {
      ...
      self.mapContainerView = self.findViewOfType("MKScrollContainerView", inView: self)
      ...
  }

  func findViewOfType(type: 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 {
              if v.dynamicType.description() == type {
                  return v
              }
              if let inSubviews = self.findViewOfType(type, inView: v) {
                  return inSubviews
              }
          }
          return nil
      } else {
          return nil
      }
  }

}

3. Calculate rotation

MKScrollContainerView is rotated simply by changing its transform property. Rotation matrix, used for that purpose, is described in Apple's documentation: https://developer.apple.com/library/mac/documentation/GraphicsImaging/Reference/CGAffineTransform/#//apple_ref/c/func/CGAffineTransformMakeRotation

It looks this way:

 cosA  sinA  0
-sinA  cosA  0
  0     0    1

The function, to calculate rotation, based on that matrix, looks this way:

class MyMap : MKMapView {

...

func getRotation() -> Double? {
    // function gets current map rotation based on the transform values of MKScrollContainerView
    if self.mapContainerView != nil {
        var rotation = fabs(180 * asin(Double(self.mapContainerView!.transform.b)) / M_PI)
        if self.mapContainerView!.transform.b <= 0 {
            if self.mapContainerView!.transform.a >= 0 {
                // do nothing
            } else {
                rotation = 180 - rotation
            }
        } else {
            if self.mapContainerView!.transform.a <= 0 {
                rotation = rotation + 180
            } else {
                rotation = 360 - rotation
            }
        }
        return rotation
    } else {
        return nil
    }
}

...

}

4. Track rotation constantly

The only way I found to do this is to have infinite loop, which checks rotation value every loop call. To implement that you need:

  1. MyMap listener
  2. function, to check rotation
  3. timer, to call function every X seconds

Here is my implementation:

@objc protocol MyMapListener {

    optional func onRotationChanged(rotation rotation: Double)
    // message is sent when map rotation is changed

}


class MyMap : MKMapView {

    ...
    var changesTimer : NSTimer? // timer to track map changes; nil when changes are not tracked
    var listener : MyMapListener?
    var rotation : Double = 0 // value to track rotation changes

    ...

    func trackChanges() {
        // function detects map changes and processes it
        if let rotation = self.getRotation() {
            if rotation != self.rotation {
                self.rotation = rotation
                self.listener?.onRotationChanged(rotation: rotation)
            }
        }
    }


    func startTrackingChanges() {
        // function starts tracking map changes
        if self.changesTimer == nil {
            self.changesTimer = NSTimer(timeInterval: 0.1, target: self, selector: #selector(MyMap.trackChanges), userInfo: nil, repeats: true)
            NSRunLoop.currentRunLoop().addTimer(self.changesTimer!, forMode: NSRunLoopCommonModes)
        }
    }


    func stopTrackingChanges() {
        // function stops tracking map changes
        if self.changesTimer != nil {
            self.changesTimer!.invalidate()
            self.changesTimer = nil
        }
    }


}

That's all ;)

You can download sample project in my repo: https://github.com/d-babych/mapkit-wrap

1
votes

Thanks @Dmytro Babych for logic to find MKScrollContainerView. But in my case, I use KVO to observe MKScrollContainerView layer rotation. This code written on swift 4.

1. Subclass MKMapView and add rotation delegate protocol

protocol MyMapViewRotationDelegate:class {
    func myMapView(mapView: MyMapView, didRotateAtAngle angle:CGFloat)
}

class MyMapView: MKMapView {
    private var mapContainerView:UIView?
    weak var rotationDelegate:MyMapViewRotationDelegate?
}

2. Find MKScrollContainerView in init() or awakeFromNib() if you using storyboard. And add KVO observer to layer

private func findViewOfType(type: 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 {
                if v.classForCoder == NSClassFromString("MKScrollContainerView") {
                    return v
                }

                if let inSubviews = self.findViewOfType(type: type, inView: v) {
                    return inSubviews
                }
            }
            return nil
        } else {
            return nil
        }
    }

override func awakeFromNib() {
        super.awakeFromNib()

        if let scrollContainerView = findViewOfType(type: "MKScrollContainerView", inView: self) {
            mapContainerView = scrollContainerView
            mapContainerView!.layer.addObserver(self, forKeyPath: #keyPath(transform), options: [.new, .old], context: nil)
        }
    }

3. Override observeValue function in MyMapView class

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {        
        guard keyPath == #keyPath(transform) else {
            super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
            return
        }
            guard let layer = object as? CALayer else {
                return
            }

            if let rotationRadians = layer.value(forKeyPath: "transform.rotation.z") as? CGFloat {
                var angle = rotationRadians / .pi * 180 //convert to degrees
                if  angle < 0 {
                    angle = 360 + angle
                }

                if let rotationDelegate = rotationDelegate {
                    rotationDelegate.myMapView(mapView: self, didRotateAtAngle: angle)
                }
            }
        }

4. Don't forget to remove observer when MyMapView will be deinited

deinit {
        mapContainerView?.layer.removeObserver(self, forKeyPath: #keyPath(transform))
    }
0
votes

You could try creating a CADisplayLink, which will fire a selector on screen refreshes, which will be enough to sync with each frame of MapKit's animations. In each pass, check the direction value and update your annotation view.

0
votes

There seem to exist indeed no way to track the simply read the current heading while rotation the map. Since I just implemented a compass view that rotates with the map, I want to share my knowledge with you.

I explicitly invite you to refine this answer. Since I have a deadline, I'm satisfied as it is now (before that, the compass was only set in the moment the map stopped to rotate) but there is room for improvement and finetuning.

I uploaded a sample project here: MapRotation Sample Project

Okay, let's start. Since I assume you all use Storyboards nowadays, drag a few gesture recognizers to the map. (Those who don't surely knows how to convert these steps into written lines.)

To detect map rotation, zoom and 3D angle we need a rotation, a pan and a pinch gesture recognizer. Drag Gesture Recognizers on the MapView

Disable "Delays touches ended" for the Rotation Gesture Recognizer... Disable "Delays touches ended" for the Rotation Gesture Recognizer

... and increase "Touches" to 2 for the Pan Gesture Recognizer. Increase "Touches" to 2 for the Pan Gesture Recognizer

Set the delegate of these 3 to the containing view controller. Ctrl-drag to the containing view controller... ... and set the delegate.

Drag for all 3 gesture recognizers the Referencing Outlet Collections to the MapView and select "gestureRecognizers"

enter image description here

Now Ctrl-drag the rotation gesture recognizer to the implementation as Outlet like this:

@IBOutlet var rotationGestureRecognizer: UIRotationGestureRecognizer!

and all 3 recognizers as IBAction:

@IBAction func handleRotation(sender: UIRotationGestureRecognizer) {
    ...
}

@IBAction func handleSwipe(sender: UIPanGestureRecognizer) {
    ...
}

@IBAction func pinchGestureRecognizer(sender: UIPinchGestureRecognizer) {
    ...
}

Yes, I named the pan gesture "handleSwype". It's explained below. :)

Listed below the complete code for the controller that of course also has to implement the MKMapViewDelegate protocol. I tried to be very detailed in the comments.

// compassView is the container View,
// arrowImageView is the arrow which will be rotated
@IBOutlet weak var compassView: UIView!
var arrowImageView = UIImageView(image: UIImage(named: "Compass")!)

override func viewDidLoad() {
    super.viewDidLoad()
    compassView.addSubview(arrowImageView)
}

// ******************************************************************************************
//                                                                                          *
// Helper: Detect when the MapView changes                                                  *

private func mapViewRegionDidChangeFromUserInteraction() -> Bool {
    let view = mapView!.subviews[0]
    // Look through gesture recognizers to determine whether this region
    // change is from user interaction
    if let gestureRecognizers = view.gestureRecognizers {
        for recognizer in gestureRecognizers {
            if( recognizer.state == UIGestureRecognizerState.Began ||
                recognizer.state == UIGestureRecognizerState.Ended ) {
                return true
            }
        }
    }
    return false
}
//                                                                                          *
// ******************************************************************************************



// ******************************************************************************************
//                                                                                          *
// Helper: Needed to be allowed to recognize gestures simultaneously to the MapView ones.   *

func gestureRecognizer(_: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
        return true
}
//                                                                                          *
// ******************************************************************************************



// ******************************************************************************************
//                                                                                          *
// Helper: Use CADisplayLink to fire a selector at screen refreshes to sync with each       *
// frame of MapKit's animation

private var displayLink : CADisplayLink!

func setUpDisplayLink() {
    displayLink = CADisplayLink(target: self, selector: "refreshCompassHeading:")
    displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes)
}
//                                                                                          *
// ******************************************************************************************





// ******************************************************************************************
//                                                                                          *
// Detect if the user starts to interact with the map...                                    *

private var mapChangedFromUserInteraction = false

func mapView(mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
    
    mapChangedFromUserInteraction = mapViewRegionDidChangeFromUserInteraction()
    
    if (mapChangedFromUserInteraction) {
        
        // Map interaction. Set up a CADisplayLink.
        setUpDisplayLink()
    }
}
//                                                                                          *
// ******************************************************************************************
//                                                                                          *
// ... and when he stops.                                                                   *

func mapView(mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
    
    if mapChangedFromUserInteraction {
        
        // Final transform.
        // If all calculations would be correct, then this shouldn't be needed do nothing.
        // However, if something went wrong, with this final transformation the compass
        // always points to the right direction after the interaction is finished.
        // Making it a 500 ms animation provides elasticity und prevents hard transitions.
        
        UIView.animateWithDuration(0.5, animations: {
            self.arrowImageView.transform =
                CGAffineTransformMakeRotation(CGFloat(M_PI * -mapView.camera.heading) / 180.0)
        })
        
        
        
        // You may want this here to work on a better rotate out equation. :)
        
        let stoptime = NSDate.timeIntervalSinceReferenceDate()
        print("Needed time to rotate out:", stoptime - startRotateOut, "with velocity",
            remainingVelocityAfterUserInteractionEnded, ".")
        print("Velocity decrease per sec:", (Double(remainingVelocityAfterUserInteractionEnded)
            / (stoptime - startRotateOut)))
        
        
        
        // Clean up for the next rotation.
        
        remainingVelocityAfterUserInteractionEnded = 0
        initialMapGestureModeIsRotation = nil
        if let _ = displayLink {
            displayLink.invalidate()
        }
    }
}
//                                                                                          *
// ******************************************************************************************





// ******************************************************************************************
//                                                                                          *
// This is our main function. The display link calls it once every display frame.           *

// The moment the user let go of the map.
var startRotateOut = NSTimeInterval(0)

// After that, if there is still momentum left, the velocity is > 0.
// The velocity of the rotation gesture in radians per second.
private var remainingVelocityAfterUserInteractionEnded = CGFloat(0)

// We need some values from the last frame
private var prevHeading = CLLocationDirection()
private var prevRotationInRadian = CGFloat(0)
private var prevTime = NSTimeInterval(0)

// The momentum gets slower ower time
private var currentlyRemainingVelocity = CGFloat(0)

func refreshCompassHeading(sender: AnyObject) {
    
    // If the gesture mode is not determinated or user is adjusting pitch
    // we do obviously nothing here. :)
    if initialMapGestureModeIsRotation == nil || !initialMapGestureModeIsRotation! {
        return
    }
    

    let rotationInRadian : CGFloat
    
    if remainingVelocityAfterUserInteractionEnded == 0 {
        
        // This is the normal case, when the map is beeing rotated.
        rotationInRadian = rotationGestureRecognizer.rotation
        
    } else {
        
        // velocity is > 0 or < 0.
        // This is the case when the user ended the gesture and there is
        // still some momentum left.
        
        let currentTime = NSDate.timeIntervalSinceReferenceDate()
        let deltaTime = currentTime - prevTime
        
        // Calculate new remaining velocity here.
        // This is only very empiric and leaves room for improvement.
        // For instance I noticed that in the middle of the translation
        // the needle rotates a bid faster than the map.
        let SLOW_DOWN_FACTOR : CGFloat = 1.87
        let elapsedTime = currentTime - startRotateOut

        // Mathematicians, the next line is for you to play.
        currentlyRemainingVelocity -=
            currentlyRemainingVelocity * CGFloat(elapsedTime)/SLOW_DOWN_FACTOR
        
        
        let rotationInRadianSinceLastFrame =
        currentlyRemainingVelocity * CGFloat(deltaTime)
        rotationInRadian = prevRotationInRadian + rotationInRadianSinceLastFrame
        
        // Remember for the next frame.
        prevRotationInRadian = rotationInRadian
        prevTime = currentTime
    }
    
    // Convert radian to degree and get our long-desired new heading.
    let rotationInDegrees = Double(rotationInRadian * (180 / CGFloat(M_PI)))
    let newHeading = -mapView!.camera.heading + rotationInDegrees
    
    // No real difference? No expensive transform then.
    let difference = abs(newHeading - prevHeading)
    if difference < 0.001 {
        return
    }

    // Finally rotate the compass.
    arrowImageView.transform =
        CGAffineTransformMakeRotation(CGFloat(M_PI * newHeading) / 180.0)

    // Remember for the next frame.
    prevHeading = newHeading
}
//                                                                                          *
// ******************************************************************************************



// As soon as this optional is set the initial mode is determined.
// If it's true than the map is in rotation mode,
// if false, the map is in 3D position adjust mode.

private var initialMapGestureModeIsRotation : Bool?



// ******************************************************************************************
//                                                                                          *
// UIRotationGestureRecognizer                                                              *

@IBAction func handleRotation(sender: UIRotationGestureRecognizer) {
    
    if (initialMapGestureModeIsRotation == nil) {
        initialMapGestureModeIsRotation = true
    } else if !initialMapGestureModeIsRotation! {
        // User is not in rotation mode.
        return
    }
    
    
    if sender.state == .Ended {
        if sender.velocity != 0 {

            // Velocity left after ending rotation gesture. Decelerate from remaining
            // momentum. This block is only called once.
            remainingVelocityAfterUserInteractionEnded = sender.velocity
            currentlyRemainingVelocity = remainingVelocityAfterUserInteractionEnded
            startRotateOut = NSDate.timeIntervalSinceReferenceDate()
            prevTime = startRotateOut
            prevRotationInRadian = rotationGestureRecognizer.rotation
        }
    }
}
//                                                                                          *
// ******************************************************************************************
//                                                                                          *
// Yes, there is also a SwypeGestureRecognizer, but the length for being recognized as      *
// is far too long. Recognizing a 2 finger swype up or down with a PanGestureRecognizer
// yields better results.

@IBAction func handleSwipe(sender: UIPanGestureRecognizer) {
    
    // After a certain altitude is reached, there is no pitch possible.
    // In this case the 3D perspective change does not work and the rotation is initialized.
    // Play with this one.
    let MAX_PITCH_ALTITUDE : Double = 100000
    
    // Play with this one for best results detecting a swype. The 3D perspective change is
    // recognized quite quickly, thats the reason a swype recognizer here is of no use.
    let SWYPE_SENSITIVITY : CGFloat = 0.5 // play with this one
    
    if let _ = initialMapGestureModeIsRotation {
        // Gesture mode is already determined.
        // Swypes don't care us anymore.
        return
    }
    
    if mapView?.camera.altitude > MAX_PITCH_ALTITUDE {
        // Altitude is too high to adjust pitch.
        return
    }
    
    
    let panned = sender.translationInView(mapView)
    
    if fabs(panned.y) > SWYPE_SENSITIVITY {
        // Initial swype up or down.
        // Map gesture is most likely a 3D perspective correction.
        initialMapGestureModeIsRotation = false
    }
}
//                                                                                          *
// ******************************************************************************************
//                                                                                          *

@IBAction func pinchGestureRecognizer(sender: UIPinchGestureRecognizer) {
    // pinch is zoom. this always enables rotation mode.
    if (initialMapGestureModeIsRotation == nil) {
        initialMapGestureModeIsRotation = true
        // Initial pinch detected. This is normally a zoom
        // which goes in hand with a rotation.
    }
}
//                                                                                          *
// ******************************************************************************************