Search code examples
iosmkmapviewmkannotationviewmapkit

How to adjust region to fit custom annotation callout that have just appeared?


I use my custom subclass of MKAnnotationView. In mapView:didSelectAnnotationView: method of my Map's delegate I call the method of this class, which adds UIImageView with an image as a subview - it serves as my custom annotation callout.

When using default MKPinAnnotationView map does automatically adjust map region to display the annotation callout that have just appeared. How can I implement this behavior using custom MKAnnotationView subclass?


Solution

  • Current solution

    I've crafted demo project having the stuff discussed below implemented: see there AdjustRegionToFitAnnotationCallout project.

    The latest iOS7 changes in how Map Kit's MKMapView renders map annotations made me to revisit this problem. I've made more accurate thinking about it and come up with much, very much better solution. I will leave the previous solution at the bottom of this answer, but remember - I was so wrong when I did it that way.

    First of all we will need a helper CGRectTransformToContainRect() that expands a given CGRect to contain another CGRect.

    Note: it's behavior is different from what CGRectUnion() does - CGRectUnion() returns just the smallest CGRect containing both CGRects, whereas the following helper allows parallel movement i.e. CGRectTransformToContainRect(CGRectMake(0, 0, 100, 100), CGRectMake(50, 50, 100, 100)) equals (CGRect){50, 50, 100, 100} and not (CGRect){0, 0, 150, 150} like CGRectUnion() does it. This behavior is exactly what we need when we want to have only adjusts using parallel movements and want to avoid map's zooming.

    static inline CGRect CGRectTransformToContainRect(CGRect rectToTransform, CGRect rectToContain) {
        CGFloat diff;
        CGRect transformedRect = rectToTransform;
    
    
        // Transformed rect dimensions should encompass the dimensions of both rects
        transformedRect.size.width = MAX(CGRectGetWidth(rectToTransform), CGRectGetWidth(rectToContain));
        transformedRect.size.height = MAX(CGRectGetHeight(rectToTransform), CGRectGetHeight(rectToContain));
    
    
        // Comparing max X borders of both rects, adjust if
        if ((diff = CGRectGetMaxX(rectToContain) - CGRectGetMaxX(transformedRect)) > 0) {
            transformedRect.origin.x += diff;
        }
        // Comparing min X borders of both rects, adjust if
        else if ((diff = CGRectGetMinX(transformedRect) - CGRectGetMinX(rectToContain)) > 0) {
            transformedRect.origin.x -= diff;
        }
    
    
        // Comparing max Y borders of both rects, adjust if
        if ((diff = CGRectGetMaxY(rectToContain) - CGRectGetMaxY(transformedRect)) > 0) {
            transformedRect.origin.y += diff;
        }
        // Comparing min Y borders of both rects, adjust if
        else if ((diff = CGRectGetMinY(transformedRect) - CGRectGetMinY(rectToContain)) > 0) {
            transformedRect.origin.y -= diff;
        }
    
    
        return transformedRect;
    }
    
    Adjust method wrapped into an Objective-C category MKMapView(Extensions):
    
    @implementation MKMapView (Extensions)
    
    - (void)adjustToContainRect:(CGRect)rect usingReferenceView:(UIView *)referenceView  animated:(BOOL)animated {
        // I just like this assert here
        NSParameterAssert(referenceView);
    
        CGRect visibleRect = [self convertRegion:self.region toRectToView:self];
    
        // We convert our annotation from its own coordinate system to a coodinate system of a map's top view, so we can compare it with the bounds of the map itself
        CGRect annotationRect = [self convertRect:rect fromView:referenceView.superview];
    
        // Fatten the area occupied by your annotation if you want to have a margin after adjustment
        CGFloat additionalMargin = 2;
        adjustedRect.origin.x -= additionalMargin;
        adjustedRect.origin.y -= additionalMargin;
        adjustedRect.size.width += additionalMargin * 2;
        adjustedRect.size.height += additionalMargin * 2;
    
        // This is the magic: if the map must expand its bounds to contain annotation, it will do this 
        CGRect adjustedRect = CGRectTransformToContainRect(visibleRect, annotationRect);
    
        // Now we just convert adjusted rect to a coordinate region
        MKCoordinateRegion adjustedRegion = [self convertRect:adjustedRect toRegionFromView:self];
    
        // Trivial regionThatFits: sugar and final setRegion:animated: call
        [self setRegion:[self regionThatFits:adjustedRegion] animated:animated];
    }
    
    @end
    

    Now the controller and views:

    @interface AnnotationView : MKAnnotationView
    @property AnnotationCalloutView *calloutView;
    @property (readonly) CGRect annotationViewWithCalloutViewFrame;
    @end
    
    @implementation AnnotationView 
    
    - (void)showCalloutBubble {
        // This is a code where you create your custom annotation callout view
        // add add it using -[self addSubview:]
        // At the end of this method a callout view should be displayed.
    }
    
    - (CGRect)annotationViewWithCalloutViewFrame {
        // Here you should adjust your annotation frame so it match itself in the moment when annotation callout is displayed and ...
    
        return CGRectOfAdjustedAnnotation; // ...
    }
    
    @end
    

    When AnnotationView-classed annotation is selected on map, it adds its calloutView as a subview, so custom annotation callout view is displayed. It is done using MKMapViewDelegate's method:

    - (void)mapView:(MapView *)mapView didSelectAnnotationView:(MKAnnotationView *)view {
        // AnnotationPresenter is just a class that contains information to be displayed on callout annotation view
        if ([view.annotation isKindOfClass:[AnnotationPresenter class]]) {
            // Hide another annotation if it is shown
            if (mapView.selectedAnnotationView != nil && [mapView.selectedAnnotationView isKindOfClass:[AnnotationView class]] && mapView.selectedAnnotationView != view) {
                [mapView.selectedAnnotationView hideCalloutBubble];
            }
            mapView.selectedAnnotationView = view;
    
            annotationView *annotationView = (annotationView *)view;
    
            // This just adds *calloutView* as a subview    
            [annotationView showCalloutBubble];
    
            [mapView adjustToContainRect:annotationView.annotationViewWithCalloutViewFrame usingReferenceView:annotationView animated:NO];
        }
    }
    

    Of course your implementation may be different from what I've described here (mine is!). The most important part of above code is of course the [MKMapView adjustToContainRect:usingReferenceView:animated: method. Now I am really satisfied with the current solution and my understanding of this (and some related) problem. If you need any comments about the solution above, feel free to contact me (see profile).

    The following Apple docs are very useful to understand what is going on in methods like -[MKMapView convertRect:fromView:]:

    http://developer.apple.com/library/ios/#documentation/MapKit/Reference/MKMapView_Class/MKMapView/MKMapView.html

    http://developer.apple.com/library/ios/#documentation/MapKit/Reference/MapKitDataTypesReference/Reference/reference.html

    http://developer.apple.com/library/ios/#documentation/MapKit/Reference/MapKitFunctionsReference/Reference/reference.html

    Also the first 10-15 minutes of WWDC 2013 session "What’s New in Map Kit" (#304) are very good to watch to have an excellent quick demo of the whole "Map with annotations" setup done by Apple engineer.


    Initial solution (Does not work in iOS7, do not use it, use the solution above instead)

    Somehow I forgot to answer my question at a time. Here is the complete solution I use nowadays (edited slightly for readability):

    First of all a bit of map logic to be encapsulated somewhere in helpers file like MapKit+Helpers.h

    typedef struct {
        CLLocationDegrees top;
        CLLocationDegrees bottom;
    } MKLatitudeEdgedSpan;
    
    typedef struct {
        CLLocationDegrees left;
        CLLocationDegrees right;
    } MKLongitudeEdgedSpan;
    
    typedef struct {
        MKLatitudeEdgedSpan latitude;
        MKLongitudeEdgedSpan longitude;
    } MKEdgedRegion;
    
    MKEdgedRegion MKEdgedRegionFromCoordinateRegion(MKCoordinateRegion region) {
        MKEdgedRegion edgedRegion;
    
        float latitude = region.center.latitude;
        float longitude = region.center.longitude;
        float latitudeDelta = region.span.latitudeDelta;
        float longitudeDelta = region.span.longitudeDelta;
    
        edgedRegion.longitude.left = longitude - longitudeDelta / 2;
        edgedRegion.longitude.right = longitude + longitudeDelta / 2;
        edgedRegion.latitude.top = latitude + latitudeDelta / 2;
        edgedRegion.latitude.bottom = latitude - latitudeDelta / 2;
    
        return edgedRegion;
    }
    

    Like MKCoordinateRegion (center coordinate + spans), MKEdgedRegion is just a way to define a region but using coordinates of its edges instead.

    MKEdgedRegionFromCoordinateRegion() is a self-explanatory converter-method.

    Suppose we have the following class for our annotations, containing its callout as a subview.

    @interface AnnotationView : MKAnnotationView
    @property AnnotationCalloutView *calloutView;
    @end
    

    When AnnotationView-classed annotation is selected on map, it adds its calloutView as a subview, so custom annotation callout view is displayed. It is done using MKMapViewDelegate's method:

    - (void)mapView:(MapView *)mapView didSelectAnnotationView:(MKAnnotationView *)view {
        // AnnotationPresenter is just a class that contains information to be displayed on callout annotation view
        if ([view.annotation isKindOfClass:[AnnotationPresenter class]]) {
            // Hide another annotation if it is shown
            if (mapView.selectedAnnotationView != nil && [mapView.selectedAnnotationView isKindOfClass:[AnnotationView class]] && mapView.selectedAnnotationView != view) {
                [mapView.selectedAnnotationView hideCalloutBubble];
            }
            mapView.selectedAnnotationView = view;
    
            annotationView *annotationView = (annotationView *)view;
    
            // This just adds *calloutView* as a subview    
            [annotationView showCalloutBubble];
    
            /* Here the trickiest piece of code goes */
    
            /* 1. We capture _annotation's (not callout's)_ frame in its superview's (map's!) coordinate system resulting in something like (CGRect){4910547.000000, 2967852.000000, 23.000000, 28.000000} The .origin.x and .origin.y are especially important! */
            CGRect annotationFrame = annotationView.frame;
    
            /* 2. Now we need to perform an adjustment, so our frame would correspond to the annotation view's _callout view subview_ that it holds. */
            annotationFrame.origin.x = annotationFrame.origin.x + ANNOTATION_CALLOUT_TRIANLE_HALF; // Mine callout view has small x offset - you should choose yours!
            annotationFrame.origin.y = annotationFrame.origin.y - ANNOTATION_CALLOUT_HEIGHT / 2; // Again my custom offset.
            annotationFrame.size = placeAnnotationView.calloutView.frame.size; // We can grab calloutView size directly because in its case we don't care about the coordinate system.
    
            MKCoordinateRegion mapRegion = mapView.region;
    
            /* 3. This was a long run before I did stop to try to pass mapView.view as an argument to _toRegionFromView_. */
            /* annotationView.superView is very important - it gives us the same coordinate system that annotationFrame.origin is based. */
            MKCoordinateRegion annotationRegion = [mapView convertRect:annotationFrame toRegionFromView:annotationView.superview];
    
            /* I hope that the following MKEdgedRegion magic is self-explanatory */
            MKEdgedRegion mapEdgedRegion = MKEdgedRegionFromCoordinateRegion(mapRegion);
            MKEdgedRegion annotationEdgedRegion = MKEdgedRegionFromCoordinateRegion(annotationRegion);
    
            float diff;
    
            if ((diff = (annotationEdgedRegion.longitude.left - mapEdgedRegion.longitude.left)) < 0 ||
                (diff = (annotationEdgedRegion.longitude.right - mapEdgedRegion.longitude.right)) > 0)
                mapRegion.center.longitude += diff;
    
            if ((diff = (annotationEdgedRegion.latitude.bottom - mapEdgedRegion.latitude.bottom)) < 0 ||
                (diff = (annotationEdgedRegion.latitude.top - mapEdgedRegion.latitude.top)) > 0)
                mapRegion.center.latitude += diff;
    
            mapView.region = mapRegion;
        }
    }