2012-04-04

Animated Circle on MKMapView



When your app needs to specific a region on a MKMapView, high chance is your client/customer will ask if you could provide an animated circle similar to the one in the Maps App used for indicating the current location.

Moreover, what you are doing is possibly trying to indicate a region estimation, that you need the circle can re-scale with the zoom-in and zoom-out of the MKMapView, also animate the size a little bit to indicate the estimation.

Do a quick google search, you may find many suggestions, possibly you can't find any concrete solution or example, however.

Let's understand what's going on first. (Or you are too tired to read. Source code is available in the YHMapDemo repository. Make sure you read "Using my code" section first.)

First of all, the animated circle used in the Maps App is not available in the iOS SDK at the moment. It's not in the scope of the public API.

The immediate direction for you can think of, out of many possible solutions, will be subclass the MKOverlayView or MKCircleView and do some magic on that. Yes, I agree with you.

Basically, the most straight forward way is about overriding the drawMapRect:zoomScale:inContext: method and draw what you need. This is the way to do. Many people has been doing this way for custom map overlay I'm pretty sure.

However, things get complicated when it comes to animation.

You can of course convert the size of the circle from pixel to map coordinate (MKMapPoint and MKMapRect), with respect to a scale value changing along with a NSTimer. However, you will encounter a lot of threading issues, which the drawing of circle becomes out of control. Also, the redraw can also be very sluggish.

And if you zoom into the map which your circle cover a lot of space on the map, you will surely address a lot of drawing artifacts. (Due to the map redraws on a lot of small tiles one by one, instead of the whole map at once, your circle will flicker and some squares flashing within).

Issues:
  • Sluggish redraw by NSTimer
  • Threading issue when map redraw
  • Redraw tiling artifact at zoom in
Holy shhh... No, as always, don't panic!

The solution I would like to provide to you, is actually rather simple (everything is simple if it's telling to you by someone else. Ask me how much time I come up with this in private lol): by adding an UIImageView on the MKCircleView subclass (you may change to MKOverlayView on your needs, I want the radius property from MKCircle here, though) . We calculate the size respect to the map coordinate, and using Core Animation to provide the animation you need. (Animate the UIImageView with many images? How much images you will need to prepare for a smooth resize animation? Just saying...)

All the magic is done inside YHAnimatedCircleView, a subclass of MKCircleView which basically do all the specific tricks we have discussed. (The way to use this class is exactly the same as MKCircleView)

Advantages:
  • Seamless animation by Core Animation
  • Perfect performance on any zoom level



Looks promising to you? Let's dig into the code.

On wait. I assume you know all the basic about using the MKMapView, i.e. set up the MKMapView, adding annotation and adding MKCircle as overlay (Learn it here, otherwise). I'll go straight into the YHAnimatedCircleView.

First of all, let's take a look on the helper function first.



-(CGRect)rectForCircle{
   
   //the circle center
   MKMapPoint mpoint = MKMapPointForCoordinate([[self overlay] coordinate]);
   
   //geting the radius in map point
   double radius = [(MKCircle*)[self overlay] radius];    
   double mapRadius = radius * MKMapPointsPerMeterAtLatitude([[self overlay] coordinate].latitude);
   
   //calculate the rect in map coordinate
   MKMapRect mrect = MKMapRectMake(mpoint.x - mapRadius, mpoint.y - mapRadius, mapRadius * 2, mapRadius * 2);
   
   //return the pixel coordinate circle
   return [self rectForMapRect:mrect];
}


rectForCircle helped us to find the rect of the circle in pixel coordinate. We first get the location coordinate of the circle and convert it to MKMapPoint (map coordinate). Then we also convert the radius in meter to the map coordinate. With the two information, we can calculate the MKMapRect as we have the center and radius of the circle already. Finally, convert the MKMapRect to CGRect which will be in pixel coordinate, for us to manage the UIImageView later.



-(void)removeExistingAnimation{
   
   if(imageView){
       [imageView.layer removeAllAnimations];
       [imageView removeFromSuperview];
       imageView = nil;
   }
}

This one is simple, simply remove any existing animation and nullified the imageView.



#define MAX_RATIO 1.2#define MIN_RATIO 0.8
#define MAX_OPACITY 0.5#define MIN_OPACITY 0.05
#define ANIMATION_DURATION 0.8
//repeat forever#define ANIMATION_REPEAT 1e100f 

Also, some constant that may interest you...



Done, let's start explaining the flow.



-(id)initWithCircle:(MKCircle *)circle{
   
   self = [super initWithCircle:circle];
   
   if(self){
       [self start];
   }   
   
   return self;
}



As the circle view is initiated, we want the animation being initiated immediately as well.



-(void)start{

   [self removeExistingAnimation];
       
   CGRect rect = [self rectForCircle];
   
   //create the image
   UIImage* img = [UIImage imageNamed:@"redCircle.png"];
   imageView = [[UIImageView alloc] initWithImage:img];
   imageView.frame = rect;
   [self addSubview:imageView];
   [imageView release];
   
   //opacity animation setup
   CABasicAnimation *opacityAnimation;
   
   opacityAnimation=[CABasicAnimation animationWithKeyPath:@"opacity"];
   opacityAnimation.duration = ANIMATION_DURATION;
   opacityAnimation.repeatCount = ANIMATION_REPEAT;
   //theAnimation.autoreverses=YES;
   opacityAnimation.fromValue = [NSNumber numberWithFloat:MAX_OPACITY];
   opacityAnimation.toValue = [NSNumber numberWithFloat:MIN_OPACITY];
   
   //resize animation setup
   CABasicAnimation *transformAnimation;
   
   transformAnimation = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
   
   transformAnimation.duration = ANIMATION_DURATION;
   transformAnimation.repeatCount = ANIMATION_REPEAT;
   //transformAnimation.autoreverses=YES;
   transformAnimation.fromValue = [NSNumber numberWithFloat:MIN_RATIO];
   transformAnimation.toValue = [NSNumber numberWithFloat:MAX_RATIO];
   
   
   //group the two animation
   CAAnimationGroup *group = [CAAnimationGroup animation];

   group.repeatCount = ANIMATION_REPEAT;
   [group setAnimations:[NSArray arrayWithObjects:opacityAnimation, transformAnimation, nil]];
   group.duration = ANIMATION_DURATION;

   //apply the grouped animation
   [imageView.layer addAnimation:group forKey:@"groupAnimation"];
}



This block of code is a bit of large, but actually very simple and clear. First of all, we want to make sure there's no other UIImageView is animating in the every call for start. That's why we call the help function removeExistAnimation to make sure of it.

Then, we create the UIImageView which contains the circle image and add it to our view.

The animation we want here, is the image will change in size along with opacity simultaneously.  Therefore, we initiate two CABasicAnimation objects, which target to the opacity and transform.scale property of the CALayer respectively. Then, the two animation objects set into a CAnimationGroup together and added to the CALayer of the UIImageView.

We used some constant here, which the circle size will vary from 0.8 to 1.2 times of its radius. The opacity will be from 0.5 to 0.05. It means the large the circle, the more transparent it will be.

As we need the Core Animation, remember to add the QuartzCore framework to the project as well.



- (void)drawMapRect:(MKMapRect)mapRect
         zoomScale:(MKZoomScale)zoomScale
         inContext:(CGContextRef)ctx
{
   //get the rect in pixel coordinate and set to the imageView
   CGRect rect = [self rectForCircle];
    
   if(imageView){
       imageView.frame = rect;
   }
}



We also need to override the drawMapRect:zoomScale:inContext: method as well. This part is a bit of magic, we really need to calculate the rect here on to this point, which the circle view will be called for a redraw, so we can get a correct CGRect for the UIImageView.

That's it! you can have a seamless animated circle on your map. Clean and simple.




Looks cool, huh? =)

Of course you can modify all the constant and also the image to have different and much cooler appearance of the circle/overlay and the animation.

Note that, the animation object will be removed from the CALayer of the UIImage when the MKMapView did disappear internally. And If your MKMapView did appear again, the CALayer UIImageView will be without an animation object and it will appear as the image itself. In my sample project,  it's a concrete red circle. Therefore, be minded that you have to manage this in your code. I suggest you can simple remove the overlay in viewDidDisappear and add the overlay back again in viewDidAppear.

The class is quite simple and easy to understand, guess most of you can understand it right away once you look into the YHAnimatedCircleView source code.

Okay, so this is it! Source code is available in the YHMapDemo repository of my GitHub.

***Please read "Using my code" before using the code in your project, either a free or commercial product.***

Enjoy =)


18 comments:

  1. hey great post, thanks I was looking a way to do this animation, I am working with clusters and I would like to modify their size, what you already did, and move them, any suggestion for a Smooth effect for move the the Marker,
    thanks again

    ReplyDelete
  2. Thanks. For smooth effect do you mean animate the change in position? To make it smooth I'm pretty sure it has to be done by Core Animation (You won't get anything acceptable by redrawing). I'll see if I can come up with anything this weekend. You may also let me know if you got any results :)

    ReplyDelete
  3. Very nice post, Thanks but that red circle is not appearing when I add these files to another project.
    Do you have any idea, why this happening? Do I need to do any settings in my app?
    Thanks....

    ReplyDelete
    Replies
    1. Shrikant, it will be a bit difficult to help without a bit more details. Maybe you can provide a little bit information on what you have done on moving the classes over to your app?

      Delete
    2. I am just doing copy-paste the files of your project to in my project and giving a call to mapview after clicking on a button. Do I need to do any changes in plist file? or need to import any extra library files to my project?

      Delete
    3. So if you run the source project on your simulator/device, it works? If yes, I think you may double check if all the files (including the redCircle.png) has been copied and the way you use the ViewController is correct. (I guess what you mean is you copy the ViewController.m and header and expecting to use it directly. Are you actually instantiating your base view in this class or you are using another UIViewController subclass)

      If you have directly copied the files, you should not need to import any static libraries no making change in build config besides those in the SDK mentioned in the articule.

      Delete
    4. Your project is working finely on simulator and device. Actually, I have renamed Viewcontroller as LMTViewcontroller and YHAnimatedCircleView is same as that. And calling this LMTViewcontroller using as navigationcontroller call on clicking a button in my project.

      Delete
  4. How would one set a fixed size circle irrespective of the maps zoom level? At max zoom, the animated circle fills the entire map area.

    ReplyDelete
    Replies
    1. The circle size respective to the map's zoom level is actually calculated in helper function rectForCircle. You can simply use a fix rect on the two lines, instead getting the rect from rectForCircle.

      Delete
    2. I did try setting a fixed width & height to the CGRect before its returned by the rectForCircle function but it didnt have any effect and continued to scale during zoom in/out. Could you post the code changes necessary? Much appreciated.

      Delete
  5. Anyway to do this in IOS7 now that OverlayView has been replaced with OverlayRenderer

    ReplyDelete
    Replies
    1. https://github.com/jhurray/iOS7AnimatedMapOverlay

      Delete
  6. iOS Applications Development: Both iPad and iPhone developers, these days, have experience of working on apps for diverse categories such an entertainment, healthcare, medicine, gaming, utility, education and various others. But then there are some who just claim to have such experience. So, it's important that you are able to separate the wheat from the chaff and ensure that the iOS developers that you choose have proven experience on working on iPhone and iPad apps projects for a variety of categories.

    ReplyDelete
  7. HERE IS A SAMPLE PROJECT FOR HOW TO MAKE THIS WORK IN iOS7!!!

    https://github.com/jhurray/iOS7AnimatedMapOverlay

    ReplyDelete
  8. This comment has been removed by the author.

    ReplyDelete
  9. The animation starts a few seconds after map is displayed. How can i start immediatly?

    ReplyDelete
  10. you want to allocate the animated overlay in init and add i to the map in view did load or view will appear. definitely both if you are switching views

    ReplyDelete
  11. Nice post with great details. I really appreciate your idea. Thanks for sharing.Android Application Development

    ReplyDelete