Customise iOS8 Callout bubble (Swift)
Asked Answered
S

1

13

I want to customise the iOS8 MapView Callout bubble which get visualised when clicking on a MKAnnotationView. The Default bubble is a bit limiting (having only Title,Subtitle and 2 accessory view) so I'm struggling to find an alternative solution. Here two possible ways and the relative problems I'm facing:

PROBLEM 1) CREATING A CUSTOM CALLOUT BUBBLE

Digging the Apple documentation I have found this:

When you use a custom view instead of a standard callout, you need to do extra work to make sure your callout shows and hides appropriately when users interact with it. The steps below outline the process for creating a custom callout that contains a button:

Design an NSView or UIView subclass that represents the custom callout. It’s likely that the subclass needs to implement the drawRect: method to draw your custom content. Create a view controller that initializes the callout view and performs the action related to the button. In the annotation view, implement hitTest: to respond to hits that are outside the annotation view’s bounds but inside the callout view’s bounds, as shown in Listing 6-7. In the annotation view, implement setSelected:animated: to add your callout view as a subview of the annotation view when the user clicks or taps it. If the callout view is already visible when the user selects it, the setSelected: method should remove the callout subview from the annotation view (see Listing 6-8). In the annotation view’s initWithAnnotation: method, set the canShowCallout property to NO to prevent the map from displaying the standard callout when the user selects the annotation. Listing 6-7 shows an example of implementing hitTest: to handle hits in the callout view that might be outside the bounds of the annotation view.

Listing 6-7  Responding to hits within a custom callout
- (NSView *)hitTest:(NSPoint)point
{
    NSView *hitView = [super hitTest:point];
    if (hitView == nil && self.selected) {
        NSPoint pointInAnnotationView = [self.superview convertPoint:point toView:self];
        NSView *calloutView = self.calloutViewController.view;
        hitView = [calloutView hitTest:pointInAnnotationView];
    }
    return hitView;
}

Listing 6-8 shows an example of implementing setSelected:animated: to animate the arrival and dismissal of a custom callout view when the user selects the annotation view.

Listing 6-8  Adding and removing a custom callout view
- (void)setSelected:(BOOL)selected
{
    [super setSelected:selected];

    // Get the custom callout view.
    NSView *calloutView = self.calloutViewController.view;
    if (selected) {
        NSRect annotationViewBounds = self.bounds;
        NSRect calloutViewFrame = calloutView.frame;
      // Center the callout view above and to the right of the annotation view.
        calloutViewFrame.origin.x = -(NSWidth(calloutViewFrame) - NSWidth(annotationViewBounds)) * 0.5;
        calloutViewFrame.origin.y = -NSHeight(calloutViewFrame) + 15.0;
        calloutView.frame = calloutViewFrame;

        [self addSubview:calloutView];
    } else {
        [calloutView.animator removeFromSuperview];
    }
}

Now, when I try to convert this Objective-C code to Swift I cannot find this property:

NSView *calloutView = self.calloutViewController.view;

How can I access the callout bubble view?

PROBLEM 2) MODIFYING THE DEFAULT CALLOUT BUBBLE

As said before, the default callout displayed has title,subtitle and 2 accessory view. I noticed I cannot change much about the font style of the strings or the colour of the bubble. Also if my title has more then 24 characters my accessory views positioning gets messed up. How can I avoid this problem?

Straightjacket answered 21/1, 2015 at 11:54 Comment(0)
L
19

calloutViewController is a part of custom callout view to handle events. You won't find it in MapKit or elsewhere.
Apples instructions are good. To create your own callout you should follow steps:

1. Create custom MKAnnotationView or MAPinAnnotationView
2. Override setSelected and hitTest methods in your annotation
3. Create your own callout view
4. Override hitTest and pointInside in you callout view
5. Implement MapView delegate methods didSelectAnnotationView, didDeselectAnnotationView

I have ended up with these solution that allows me to handle touches inside callout view without losing selection.

Annotation

class MapPin: MKAnnotationView {
    class var reuseIdentifier:String {
        return "mapPin"
    }

    private var calloutView:MapPinCallout?
    private var hitOutside:Bool = true

    var preventDeselection:Bool {
        return !hitOutside
    }


    convenience init(annotation:MKAnnotation!) {
        self.init(annotation: annotation, reuseIdentifier: MapPin.reuseIdentifier)

        canShowCallout = false;
    }

    override func setSelected(selected: Bool, animated: Bool) {
        let calloutViewAdded = calloutView?.superview != nil

        if (selected || !selected && hitOutside) {
            super.setSelected(selected, animated: animated)
        }

        self.superview?.bringSubviewToFront(self)

        if (calloutView == nil) {
            calloutView = MapPinCallout()
        }

        if (self.selected && !calloutViewAdded) {
            addSubview(calloutView!)
        }

        if (!self.selected) {
            calloutView?.removeFromSuperview()
        }
    }

    override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        var hitView = super.hitTest(point, withEvent: event)

        if let callout = calloutView {
            if (hitView == nil && self.selected) {
                hitView = callout.hitTest(point, withEvent: event)
            }
        }

        hitOutside = hitView == nil

        return hitView;
    }
}

Callout view

class MapPinCallout: UIView {
    override func hitTest(var point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        let viewPoint = superview?.convertPoint(point, toView: self) ?? point

        let isInsideView = pointInside(viewPoint, withEvent: event)

        var view = super.hitTest(viewPoint, withEvent: event)

        return view
    }

    override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool {
        return CGRectContainsPoint(bounds, point)
    }
}

If you need something else but buttons be responsive in callout add code to handle touches in specific views before hitTest returns view

if calloutState == .Expanded && CGRectContainsPoint(tableView.frame, viewPoint) {
    view = tableView.hitTest(convertPoint(viewPoint, toView: tableView), withEvent: event)
}

Delegate methods

func mapView(mapView: MKMapView!, didSelectAnnotationView view: MKAnnotationView!) {
    if let mapPin = view as? MapPin {
        updatePinPosition(mapPin)
    }
}

func mapView(mapView: MKMapView!, didDeselectAnnotationView view: MKAnnotationView!) {
    if let mapPin = view as? MapPin {
        if mapPin.preventDeselection {
            mapView.selectAnnotation(view.annotation, animated: false)
        }
    }
}

func updatePinPosition(pin:MapPin) {
    let defaultShift:CGFloat = 50
    let pinPosition = CGPointMake(pin.frame.midX, pin.frame.maxY)

    let y = pinPosition.y - defaultShift

    let controlPoint = CGPointMake(pinPosition.x, y)
    let controlPointCoordinate = mapView.convertPoint(controlPoint, toCoordinateFromView: mapView)

    mapView.setCenterCoordinate(controlPointCoordinate, animated: true)
}
Linguistic answered 12/2, 2015 at 21:30 Comment(7)
hi, i got this error with your code. In "MapPin" classes. the error message is "Use of undeclared type 'MapPinModelProtocol' "Thracian
Hi. Thanks. I have fixed that typo. In my case I use custom MKAnnotation class that conforms my MapPinModelProtocol. It just declares some additional fields. Anyway here it is @objc protocol MapPinModelProtocol: MKAnnotation { var points:String { get } var photoUrls:[String] { get } }Linguistic
maksimko when I put in the code i get "updatePinPosition not found". I am trying to find the code necessary to have a completely custom uiview popup in the callout bubble. Would you be so kind to put your complete code?Thacher
Hi, @HixField. Thanks. Missing updatePinPosition method added. I'll add a link to complete code asap.Linguistic
Hi. Really nice solution have you thought of creating a library and publishing it on GitHub?Straightjacket
This looks great, but I don't quite understand how to use it. I have added these two new classes and tried returning them from viewForAnnotation but that doesn't work. What are the steps to implement this?Brigidabrigit
You should have this in your callout hitTest check: if hitView == nil && self.selected { let pointInCallout = convertPoint(point, toView: callout) hitView = callout.hitTest(pointInCallout, withEvent: event)Gyrostat

© 2022 - 2024 — McMap. All rights reserved.