Tracking touches in an MKMapView

15 Dec 2011

This post is the result of me playing around with MKMapView to figure out what I can and can’t do with it, so it may ramble a bit.

Here’s the idea: I want to be able to tap on a map view and drop a pin. I don’t want to try and subclass the map view, and the MKMapViewDelegate protocol doesn’t give you access to taps.

However, it is still a normal view, and that means I can attach UIGestureRecognizers to it.

For a first, straightforward attempt, let’s just create a basic Window based iOS application. (I hear this is gone in iOS 5.0; you can get one back from Big Nerd Ranch here: Window-Based Application.) Drop an MKMapView on your window and hook it up to your App Delegate. If you want, tell it to show the user location and zoom in on whatever region.

If you have Big Nerd Ranch’s iOS Programming, the Whereami application in Chapter 5 is pretty much the starting point I’m using. There’s a link at the bottom of the post for a demo project as well.

Now, let’s add a UITapGestureRecognizer to catch taps on the map:

- (BOOL)application:(UIApplication *)app didFinishLaunchingWithOptions:(NSDictionary *)options
{
    ...
    UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
    [mapView addGestureRecognizer:tapGesture];
    [tapGesture release];
    ...
}

And the method that’ll be called when the taps are caught. We only want single taps, so let’s try and filter that out:

- (void)handleTap:(id)sender
{
    UITapGestureRecognizer *tapGesture = (UITapGestureRecognizer*)sender;

    CGPoint tapPoint = [tapGesture locationInView:mapView];
    CLLocationCoordinate2D coord = [mapView convertPoint:tapPoint toCoordinateFromView:mapView];

    NSUInteger numberOfTouches = [tapGesture numberOfTouches];

    if (numberOfTouches == 1) {
        NSLog(@"Tap location was %.0f, %.0f", tapPoint.x, tapPoint.y);
        NSLog(@"World coordinate was longitude %f, latitude %f", coord.longitude, coord.latitude);
    } else {
        NSLog(@"Number of touches was %d, ignoring", numberOfTouches);
    }
}

Build and run, and at first glance, this works just fine. Tap and watch the logs and it’ll tell you both the window location and world location that your finger dropped. You can still tap and drag to move the view around and pinch to zoom, so we haven’t broken anything.

However, try double tapping. The map will still zoom properly, but it’s still going to call handleTap:, even with the check for number of touches. Bummer.

UIGestureRecognizers can be set up to require one or more other recognizers to fail, so let’s give that a try. We’ll add another UITapGestureRecognizer and set its minimum number of taps to 2, and then require that it fail before the original recognizer will work.

...
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] init];
[doubleTap setNumberOfTapsRequired:2];
[doubleTap setCancelsTouchesInView:NO];
[mapView addGestureRecognizer:doubleTap];

UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
[tapGesture requireGestureRecognizerToFail:doubleTap];
[mapView addGestureRecognizer:tapGesture];
[tapGesture release];
[doubleTap release];
...

Build and run, and sweet! Now we’re only catching single taps in handleMap:!

However, now double tapping to zoom is broken. It looks like we’re interfering with MKMapView’s internal gesture recognizers now. Let’s take a look at those.

...
NSArray *gestureRecognizers = [mapView gestureRecognizers];
for (UIGestureRecognizer *recognizer in gestureRecognizers) {
    NSLog(@"%@", recognizer);
}
...

Running that will show us something like this:

2011-12-15 21:20:51.844 TouchTheMap[1207:207] <UILongPressGestureRecognizer: 0x5a199c0; state = Possible; cancelsTouchesInView = NO; view = <MKMapView 0x5847a40>; target= <(action=handleLongPress:, target=<MKMapView 0x5847a40>)>>
2011-12-15 21:20:51.846 TouchTheMap[1207:207] <UITapGestureRecognizer: 0x5a19200; state = Possible; view = <MKMapView 0x5847a40>; target= <(action=handleTap:, target=<MKMapView 0x5847a40>)>; must-fail = {
        <UITapGestureRecognizer: 0x5a19b90; state = Possible; view = <MKMapView 0x5847a40>; target= <(action=handleDoubleTap:, target=<MKMapView 0x5847a40>)>; numberOfTapsRequired = 2>
    }>
2011-12-15 21:20:51.846 TouchTheMap[1207:207] <UITapGestureRecognizer: 0x5a19b90; state = Possible; view = <MKMapView 0x5847a40>; target= <(action=handleDoubleTap:, target=<MKMapView 0x5847a40>)>; numberOfTapsRequired = 2; must-fail-for = {
        <UITapGestureRecognizer: 0x5a19200; state = Possible; view = <MKMapView 0x5847a40>; target= <(action=handleTap:, target=<MKMapView 0x5847a40>)>>
    }>
2011-12-15 21:20:51.846 TouchTheMap[1207:207] <UITapGestureRecognizer: 0x5a19db0; state = Possible; view = <MKMapView 0x5847a40>; target= <(action=handleTwoFingerTap:, target=<MKMapView 0x5847a40>)>; numberOfTouchesRequired = 2>

So there are already UITapGestureRecognizers in there for both single and double taps. I have no idea what the single tap one does, but the double tap one probably handles zooming. Maybe we can just hijack that one.

So rip out the double tap recognizer we added, and dig through the built-in recognizers to find the double tap one and rely on that one instead.

...
UIGestureRecognizer *builtInDoubleTap = nil;
NSArray *gestureRecognizers = [mapView gestureRecognizers];
for (UIGestureRecognizer *recognizer in gestureRecognizers) {
    if ([recognizer class] == [UITapGestureRecognizer class]) {
        if ([(UITapGestureRecognizer *)recognizer numberOfTapsRequired] == 2) {
            NSLog(@"Found double tap recognizer: %@", recognizer);
            builtInDoubleTap = recognizer;
            break;
        }
    }
}

UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
[tapGesture requireGestureRecognizerToFail:builtInDoubleTap];
[mapView addGestureRecognizer:tapGesture];
[tapGesture release];
...

Build and run, and bingo. A single tap on the map will report the tap location in the Debug console, and a double tap will still zoom properly.

I have only tested this in the simulator so far, as I’m cheap and haven’t ponied up the cash for a developer license yet. So this might not work in the wild.

A sample project is up on GitHub named TouchTheMap. I’ll be adding to that project in later posts, so if you want the version of the code as of this posting, grab the 2011-12-15_touch_tracking tag.

Update

I figured out what that single tap gesture recognizer is in there for. MKMapView uses it to catch when you tap on annotations. Unfortunately, adding your own single tap recognizer causes the built in one to no longer get called, breaking that functionality. So if you need annotations, this is probably not a good idea.