z2k
z2k

Reputation: 10410

MKMapView constantly monitor heading

I'm rendering some content in a layer that sits on top of my MKMapView. The whole thing works great with the exception of rotation. When a user rotates the map I need to be able to rotate what I'm rendering in my own layer.

The standard answer I found is to use:

NSLog(@"heading: %f", self.mapView.camera.heading");

The issue with this is that the content of the heading variable only updates when the pinch/rotate gesture is ending, not during the gesture. I need much more frequent updates.

There is no heading property on the mapView itself.

I thought maybe using KVO like such:

    // Somewhere in setup
    [self.mapView.camera addObserver:self forKeyPath:@"heading" options:NSKeyValueObservingOptionNew context:NULL];


    // KVO Callback
    -(void)observeValueForKeyPath:(NSString *)keyPath
                         ofObject:(id)object
                           change:(NSDictionary *)change
                          context:(void *)context{

        if([keyPath isEqualToString:@"heading"]){
            // New value
        }
    }

However the KVO listener never fires which isn't surprising.

Is there a method that I'm overlooking?

Upvotes: 1

Views: 2837

Answers (4)

RyuX51
RyuX51

Reputation: 2887

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.
    }
}
//                                                                                          *
// ******************************************************************************************

Upvotes: 1

incanus
incanus

Reputation: 5128

Check this answer, which you could adapt (using CADisplayLink):

MapView detect scrolling

Upvotes: 1

Hal Mueller
Hal Mueller

Reputation: 7655

I've seen similar behavior in a MapKit program for OS X. I'm using the <MKMapViewDelegate> call mapView:regionDidChangeAnimated: instead of a KVO notification on changes to heading, but I'm still only seeing the call at the end of rotations.

I just tried implementing mapView:regionWillChangeAnimated:. That does in fact get called at the beginning of rotations. Perhaps you could begin polling the region upon receipt of mapView:regionWillChangeAnimated:, cease polling on mapView:regionDidChangeAnimated:, and in between do whatever critical updates you need during the rotations.

Upvotes: 0

neowinston
neowinston

Reputation: 7764

Instead of passing a nil context, pass a value to compare with on you KVO observer, like this:

static void *CameraContext= &CameraContext;

    // Somewhere in setup
    [self.mapView.camera addObserver:self forKeyPath:@"heading" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:CameraContext];


// KVO Callback
-(void)observeValueForKeyPath:(NSString *)keyPath
                     ofObject:(id)object
                       change:(NSDictionary *)change
                      context:(void *)context{

 if (context == CameraContext) {
    if([keyPath isEqualToString:@"heading"]){
        // New value
    }
  }
}

Upvotes: 0

Related Questions