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?
Check this answer, which you could adapt (using CADisplayLink
):
MapView detect scrolling
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.
Disable "Delays touches ended" for the Rotation Gesture Recognizer...
... and increase "Touches" to 2 for the Pan Gesture Recognizer.
Set the delegate of these 3 to the containing view controller.
Drag for all 3 gesture recognizers the Referencing Outlet Collections to the MapView and select "gestureRecognizers"
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.
}
}
// *
// ******************************************************************************************
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
}
}
}
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.