UIGestureRecognizer and UIMenuController - iOS Programming: The Big Nerd Ranch Guide (2014)

iOS Programming: The Big Nerd Ranch Guide (2014)

13. UIGestureRecognizer and UIMenuController

In Chapter 12, you handled raw touches and determined their course by implementing methods from UIResponder. Sometimes you want to detect a specific pattern of touches that make a gesture, like a pinch or a swipe. Instead of writing code to detect common gestures yourself, you can use instances of UIGestureRecognizer.

A UIGestureRecognizer intercepts touches that are on their way to being handled by a view. When it recognizes a particular gesture, it sends a message to the object of your choice. There are several types of gesture recognizers built into the SDK. In this chapter, you will use three of them to allow TouchTracker users to select, move, and delete lines (Figure 13.1). You will also see how to use another interesting iOS class, UIMenuController.

Figure 13.1 TouchTracker by the end of the chapter

TouchTracker by the end of the chapter

UIGestureRecognizer Subclasses

You do not instantiate UIGestureRecognizer itself. Instead, there are a number of subclasses of UIGestureRecognizer, and each one is responsible for recognizing a particular gesture.

To use an instance of a UIGestureRecognizer subclass, you give it a target-action pair and attach it to a view. Whenever the gesture recognizer recognizes its gesture on the view, it will send the action message to its target. All UIGestureRecognizer action messages have the same form:

- (void)action:(UIGestureRecognizer *)gestureRecognizer;

When recognizing a gesture, the gesture recognizer intercepts the touches destined for the view (Figure 13.2). Thus, a view with gesture recognizers may not receive the typical UIResponder messages like touchesBegian:withEvent:.

Figure 13.2 Gesture recognizers intercept touches

Gesture recognizers intercept touches

Detecting Taps with UITapGestureRecognizer

The first UIGestureRecognizer subclass you will use is UITapGestureRecognizer. When the user taps the screen twice, all of the lines on the screen will be cleared. Open TouchTracker.xcodeproj from Chapter 12.

In BNRDrawView.m, instantiate a UITapGestureRecognizer that requires two taps to fire in initWithFrame:.

- (instancetype)initWithFrame:(CGRect)r

{

self = [super initWithFrame:r];

if (self) {

self.linesInProgress = [[NSMutableDictionary alloc] init];

self.finishedLines = [[NSMutableArray alloc] init];

self.backgroundColor = [UIColor grayColor];

self.multipleTouchEnabled = YES;

UITapGestureRecognizer *doubleTapRecognizer =

[[UITapGestureRecognizer alloc] initWithTarget:self

action:@selector(doubleTap:)];

doubleTapRecognizer.numberOfTapsRequired = 2;

[self addGestureRecognizer:doubleTapRecognizer];

}

return self;

}

When a double tap occurs on an instance of BNRDrawView, the message doubleTap: will be sent to that instance. Implement this method in BNRDrawView.m.

- (void)doubleTap:(UIGestureRecognizer *)gr

{

NSLog(@"Recognized Double Tap");

[self.linesInProgress removeAllObjects];

[self.finishedLines removeAllObjects];

[self setNeedsDisplay];

}

Notice that the argument to the action method for a gesture recognizer is the instance of UIGestureRecognizer that sent the message. In the case of a double tap, you do not need any information from the recognizer, but you will need information from the other recognizers you install later in the chapter. Build and run the application, draw a few lines, and double-tap the screen to clear them.

You may have noticed (especially on the simulator) that during a double tap the first tap draws a small red dot. This dot appears because touchesBegan:withEvent: is sent to the BNRDrawView on the first tap, creating a small line. Check the console and you will see the following sequence of events:

touchesBegan:withEvent:

Recognized Double Tap

touchesCancelled:withEvent:

Gesture recognizers work by inspecting touch events to determine if their particular gesture occurred. Before a gesture is recognized, all UIResponder messages will be delivered to a view as normal. Since a tap gesture recognizer is recognized when a touch begins and ends within a small area in a small amount of time, the UITapGestureRecognizer cannot claim the touch is a tap just yet and touchesBegan:withEvent: is sent to the view. When the tap is finally recognized, the gesture recognizer claims the touch involved in the tap for itself and no more UIResponder messages will be sent to the view for that particular touch. In order to communicate this touch take-over to the view, touchesCancelled:withEvent: is sent to the view and the NSSet of touches contains that UITouch instance.

To prevent this red dot from appearing temporarily, you can tell a UIGestureRecognizer to delay the sending of touchesBegan:withEvent: to its view if it is still possible for the gesture to be recognized.

In BNRDrawView.m, modify initWithFrame: to do just this.

UITapGestureRecognizer *doubleTapRecognizer =

[[UITapGestureRecognizer alloc] initWithTarget:self

action:@selector(doubleTap:)];

doubleTapRecognizer.numberOfTapsRequired = 2;

doubleTapRecognizer.delaysTouchesBegan = YES;

[self addGestureRecognizer:doubleTapRecognizer];

}

return self;

}

Build and run the application, draw some lines, and then double-tap to clear them. You will no longer see the red dot while double tapping.

Multiple Gesture Recognizers

Let’s add another gesture recognizer that allows the user to select a line. (Later, a user will be able to delete the selected line.) You will install another UITapGestureRecognizer on the BNRDrawView that only requires one tap.

In BNRDrawView.m, modify initWithFrame:.

[self addGestureRecognizer:doubleTapRecognizer];

UITapGestureRecognizer *tapRecognizer =

[[UITapGestureRecognizer alloc] initWithTarget:self

action:@selector(tap:)];

tapRecognizer.delaysTouchesBegan = YES;

[self addGestureRecognizer:tapRecognizer];

}

return self;

}

Now, implement tap: to log the tap to the console in BNRDrawView.m.

- (void)tap:(UIGestureRecognizer *)gr

{

NSLog(@"Recognized tap");

}

Build and run the application. Tapping once will log the appropriate message to the console. The only problem, however, is that tapping twice will trigger both tap: and doubleTap:.

In situations where you have multiple gesture recognizers, it is not uncommon to have a gesture recognizer fire when you really want another gesture recognizer to handle the work. In these cases, you set up dependencies between recognizers that say, “Just wait a moment before you fire, because this gesture might be mine!”

In initWithFrame:, make it so the tapRecognizer must wait for the doubleTapRecognizer to fail before it can assume that a single tap is not just the first of a double tap.

UITapGestureRecognizer *tapRecognizer =

[[UITapGestureRecognizer alloc] initWithTarget:self

action:@selector(tap:)];

tapRecognizer.delaysTouchesBegan = YES;

[tapRecognizer requireGestureRecognizerToFail:doubleTapRecognizer];

[self addGestureRecognizer:tapRecognizer];

Build and run the application. A single tap now takes a small amount of time to fire after the tap occurs, but double-tapping no longer triggers the tap: message.

Now, let’s build on the BNRDrawView so that the user can select lines when they are tapped. First, add a property to hold onto a selected line to the class extension in BNRDrawView.m.

@interface BNRDrawView ()

@property (nonatomic, strong) NSMutableDictionary *linesInProgress;

@property (nonatomic, strong) NSMutableArray *finishedLines;

@property (nonatomic, weak) BNRLine *selectedLine;

@end

(Notice that this property is weak: the finishedLines array will hold the strong reference to the line and selectedLine will be set to nil if the line is removed from finishedLines by clearing the screen.)

Now, in drawRect:, add some code to the bottom of the method to draw the selected line in green.

[[UIColor redColor] set];

for (NSValue *key in self.linesInProgress) {

[self strokeLine:self.linesInProgress[key]];

}

if (self.selectedLine) {

[[UIColor greenColor] set];

[self strokeLine:self.selectedLine];

}

}

Implement lineAtPoint: in BNRDrawView.m to get a BNRLine close to the given point.

- (BNRLine *)lineAtPoint:(CGPoint)p

{

// Find a line close to p

for (BNRLine *l in self.finishedLines) {

CGPoint start = l.begin;

CGPoint end = l.end;

// Check a few points on the line

for (float t = 0.0; t <= 1.0; t += 0.05) {

float x = start.x + t * (end.x - start.x);

float y = start.y + t * (end.y - start.y);

// If the tapped point is within 20 points, let's return this line

if (hypot(x - p.x, y - p.y) < 20.0) {

return l;

}

}

}

// If nothing is close enough to the tapped point, then we did not select a line

return nil;

}

(There are better ways to implement lineAtPoint:, but this simplistic implementation is OK for your current purpose.)

The point you are interested in, of course, is where the tap occurred. You can easily get this information. Every UIGestureRecognizer has a locationInView: method. Sending this message to the gesture recognizer will give you the coordinate where the gesture occurred in the coordinate system of the view that is passed as the argument.

In BNRDrawView.m, send the locationInView: message to the gesture recognizer, pass the result to lineAtPoint:, and make the returned line the selectedLine.

- (void)tap:(UIGestureRecognizer *)gr

{

NSLog(@"Recognized tap");

CGPoint point = [gr locationInView:self];

self.selectedLine = [self lineAtPoint:point];

[self setNeedsDisplay];

}

Build and run the application. Draw a few lines and then tap on one. The tapped line should appear in green, but remember that it takes a short moment before the tap is known not to be a double tap.

UIMenuController

Next you are going to make it so that when the user selects a line, a menu appears right where the user tapped that offers the option to delete that line. There is a built-in class for providing this sort of menu called UIMenuController (Figure 13.3). A menu controller has a list of UIMenuItemobjects and is presented in an existing view. Each item has a title (what shows up in the menu) and an action (the message it sends the first responder of the window).

Figure 13.3 A UIMenuController

A UIMenuController

There is only one UIMenuController per application. When you wish to present this instance, you fill it with menu items, give it a rectangle to present from, and set it to be visible.

Do this in BNRDrawView.m’s tap: method if the user has tapped on a line. If the user tapped somewhere that is not near a line, the currently selected line will be deselected, and the menu controller will hide.

- (void)tap:(UIGestureRecognizer *)gr

{

NSLog(@"Recognized tap");

CGPoint point = [gr locationInView:self];

self.selectedLine = [self lineAtPoint:point];

if (self.selectedLine) {

// Make ourselves the target of menu item action messages

[self becomeFirstResponder];

// Grab the menu controller

UIMenuController *menu = [UIMenuController sharedMenuController];

// Create a new "Delete" UIMenuItem

UIMenuItem *deleteItem = [[UIMenuItem alloc] initWithTitle:@"Delete"

action:@selector(deleteLine:)];

menu.menuItems = @[deleteItem];

// Tell the menu where it should come from and show it

[menu setTargetRect:CGRectMake(point.x, point.y, 2, 2) inView:self];

[menu setMenuVisible:YES animated:YES];

} else {

// Hide the menu if no line is selected

[[UIMenuController sharedMenuController] setMenuVisible:NO animated:YES];

}

[self setNeedsDisplay];

}

For a menu controller to appear, a view that responds to at least one action message in the UIMenuController’s menu items must be the first responder of the window – this is why you sent the message becomeFirstResponder to the BNRDrawView before setting up the menu controller.

If you have a custom view class that needs to become the first responder, you must override canBecomeFirstResponder. In BNRDrawView.m, override this method to return YES.

- (BOOL)canBecomeFirstResponder

{

return YES;

}

You can build and run the application now, but when you select a line, the menu will not appear. When being presented, the menu controller goes through each menu item and asks the first responder if it implements the action message for that item. If the first responder does not implement that method, then the menu controller will not show the associated menu item. If no menu items have their action messages implemented by the first responder, the menu is not shown at all.

To get the Delete menu item (and the menu itself) to appear, implement deleteLine: in BNRDrawView.m.

- (void)deleteLine:(id)sender

{

// Remove the selected line from the list of _finishedLines

[self.finishedLines removeObject:self.selectedLine];

// Redraw everything

[self setNeedsDisplay];

}

Build and run the application. Draw a line, tap on it, and then select Delete from the menu item.

UILongPressGestureRecognizer

Let’s test out two other subclasses of UIGestureRecognizer: UILongPressGestureRecognizer and UIPanGestureRecognizer. When you hold down on a line (a long press), that line will be selected and you can then drag it around by dragging your finger (a pan).

In this section, let’s focus on the long press recognizer. In BNRDrawView.m, instantiate a UILongPressGestureRecognizer in initWithFrame: and add it to the BNRDrawView.

[self addGestureRecognizer:tapRecognizer];

UILongPressGestureRecognizer *pressRecognizer =

[[UILongPressGestureRecognizer alloc] initWithTarget:self

action:@selector(longPress:)];

[self addGestureRecognizer:pressRecognizer];

Now when the user holds down on the BNRDrawView, the message longPress: will be sent to it. By default, a touch must be held 0.5 seconds to become a long press, but you can change the minimumPressDuration of the gesture recognizer if you like.

So far, you have worked with tap gestures. A tap is a simple gesture. By the time it is recognized, the gesture is over, and the action message has been delivered. A long press, on the other hand, is a gesture that occurs over time and is defined by three separate events.

For example, when the user touches a view, the long press recognizer notices a possible long press but must wait to see whether the touch is held long enough to become a long press gesture.

Once the user holds the touch long enough, the long press is recognized and the gesture has begun. When the user removes the finger, the gesture has ended.

Each of these events causes a change in the gesture recognizer’s state property. For instance, the state of the long press recognizer described above would be UIGestureRecognizerStatePossible, then UIGestureRecognizerStateBegan, and finally UIGestureRecognizerStateEnded.

When a gesture recognizer transitions to any state other than the possible state, it sends its action message to its target. This means the long press recognizer’s target receives the same message when a long press begins and when it ends. The gesture recognizer’s state allows the target to determine why it has been sent the action message and take the appropriate action.

Here is the plan for implementing your action method longPress:. When the view receives longPress: and the long press has begun, you will select the closest line to where the gesture occurred. This allows the user to select a line while keeping the finger on the screen (which is important in the next section when you implement panning). When the view receives longPress: and the long press has ended, you will deselect the line.

In BNRDrawView.m, implement longPress:.

- (void)longPress:(UIGestureRecognizer *)gr

{

if (gr.state == UIGestureRecognizerStateBegan) {

CGPoint point = [gr locationInView:self];

self.selectedLine = [self lineAtPoint:point];

if (self.selectedLine) {

[self.linesInProgress removeAllObjects];

}

} else if (gr.state == UIGestureRecognizerStateEnded) {

self.selectedLine = nil;

}

[self setNeedsDisplay];

}

Build and run the application. Draw a line and then hold down on it; the line will turn green and be selected and will stay that way until you let go.

UIPanGestureRecognizer and Simultaneous Recognizers

Once a line is selected during a long press, you want the user to be able to move that line around the screen by dragging it with a finger. So you need a gesture recognizer for a finger moving around the screen. This gesture is called panning, and its gesture recognizer subclass isUIPanGestureRecognizer.

Normally, a gesture recognizer does not share the touches it intercepts. Once it has recognized its gesture, it “eats” that touch, and no other recognizer gets a chance to handle it. In your case, this is bad: the entire pan gesture you want to recognize happens within a long press gesture. You need the long press recognizer and the pan recognizer to be able to recognize their gestures simultaneously. Let’s see how to do that.

First, in the class extension in BNRDrawView.m, declare that BNRDrawView conforms to the UIGestureRecognizerDelegate protocol. Then, declare a UIPanGestureRecognizer as a property so that you have access to it in all of your methods.

@interface BNRDrawView () <UIGestureRecognizerDelegate>

@property (nonatomic, strong) UIPanGestureRecognizer *moveRecognizer;

@property (nonatomic, strong) NSMutableDictionary *linesInProgress;

@property (nonatomic, strong) NSMutableArray *finishedLines;

@property (nonatomic, weak) BNRLine *selectedLine;

@end

In BNRDrawView.m, add code to initWithFrame: to instantiate a UIPanGestureRecognizer, set two of its properties, and attach it to the BNRDrawView.

[self addGestureRecognizer:pressRecognizer];

self.moveRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self

action:@selector(moveLine:)];

self.moveRecognizer.delegate = self;

self.moveRecognizer.cancelsTouchesInView = NO;

[self addGestureRecognizer:self.moveRecognizer];

There are a number of methods in the UIGestureRecognizerDelegate protocol, but you are only interested in one – gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:. A gesture recognizer will send this message to its delegate when it recognizes its gesture but realizes that another gesture recognizer has recognized its gesture, too. If this method returns YES, the recognizer will share its touches with other gesture recognizers.

In BNRDrawView.m, return YES when the _moveRecognizer sends the message to its delegate.

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer

shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)other

{

if (gestureRecognizer == self.moveRecognizer) {

return YES;

}

return NO;

}

Now when the user begins a long press, the UIPanGestureRecognizer will be allowed to keep track of this finger, too. When the finger begins to move, the pan recognizer will transition to the began state. If these two recognizers could not work simultaneously, the long press recognizer would start, and the pan recognizer would never transition to the began state or send its action message to its target.

In addition to the states you have already seen, a pan gesture recognizer supports the changed state. When a finger starts to move, the pan recognizer enters the began state and sends a message to its target. While the finger moves around the screen, the recognizer transitions to the changed state and sends its action message to its target repeatedly. Finally, when the finger leaves the screen, the recognizer’s state is set to ended, and the final message is delivered to the target.

Now you need to implement the moveLine: method that the pan recognizer sends its target. In this implementation, you will send the message translationInView: to the pan recognizer. This UIPanGestureRecognizer method returns how far the pan has moved as a CGPoint in the coordinate system of the view passed as the argument. When the pan gesture begins, this property is set to the zero point (where both x and y equal zero). As the pan moves, this value is updated – if the pan goes very far to the right, it has a high x value; if the pan returns to where it began, its translation goes back to the zero point.

In BNRDrawView.m, implement moveLine:. Notice that because you will send the gesture recognizer a method from the UIPanGestureRecognizer class, the parameter of this method must be a pointer to an instance of UIPanGestureRecognizer rather than UIGestureRecognizer.

- (void)moveLine:(UIPanGestureRecognizer *)gr

{

// If we have not selected a line, we do not do anything here

if (!self.selectedLine) {

return;

}

// When the pan recognizer changes its position...

if (gr.state == UIGestureRecognizerStateChanged) {

// How far has the pan moved?

CGPoint translation = [gr translationInView:self];

// Add the translation to the current beginning and end points of the line

CGPoint begin = self.selectedLine.begin;

CGPoint end = self.selectedLine.end;

begin.x += translation.x;

begin.y += translation.y;

end.x += translation.x;

end.y += translation.y;

// Set the new beginning and end points of the line

self.selectedLine.begin = begin;

self.selectedLine.end = end;

// Redraw the screen

[self setNeedsDisplay];

}

}

Build and run the application. Touch and hold on a line and begin dragging – and you will immediately notice that the line and your finger are way out of sync. This makes sense because you are adding the current translation over and over again to the line’s original end points. You really need the gesture recognizer to report the change in translation since the last time this method was called instead. Fortunately, you can do this. You can set the translation of a pan gesture recognizer back to the zero point every time it reports a change. Then, the next time it reports a change, it will have the translation since the last event.

Near the bottom of moveLine: in BNRDrawView.m, add the following line of code.

[self setNeedsDisplay];

[gr setTranslation:CGPointZero inView:self];

}

}

Build and run the application and move a line around. Works great!

Before moving on, let’s take a look at a property you set in the pan gesture recognizer – cancelsTouchesInView. Every UIGestureRecognizer has this property and, by default, this property is YES. This means that the gesture recognizer will eat any touch it recognizes so that the view will not have a chance to handle it via the traditional UIResponder methods, like touchesBegan:withEvent:.

Usually, this is what you want, but not always. In this case, the gesture that the pan recognizer recognizes is the same kind of touch that the view handles to draw lines using the UIResponder methods. If the gesture recognizer eats these touches, then users will not be able to draw lines.

When you set cancelsTouchesInView to NO, touches that the gesture recognizer recognizes also get delivered to the view via the UIResponder methods. This allows both the recognizer and the view’s UIResponder methods to handle the same touches. If you are curious, comment out the line that setscancelsTouchesInView to NO and build and run again to see the effects.

For the More Curious: UIMenuController and UIResponderStandardEditActions

The UIMenuController is typically responsible for showing the user an “edit” menu when it is displayed; think of a text field or text view when you press and hold. Therefore, an unmodified menu controller (one that you do not set the menu items for) already has default menu items that it presents, like Cut, Copy, and other familiar options. Each item has an action message wired up. For example, cut: is sent to the view presenting the menu controller when the Cut menu item is tapped.

All instances of UIResponder implement these methods, but, by default, these methods do not do anything. Subclasses like UITextField override these methods to do something appropriate for their context, like cut the currently selected text. The methods are all declared in theUIResponderStandardEditActions protocol.

If you override a method from UIResponderStandardEditActions in a view, its menu item will automatically appear in any menu you show for that view. This works because the menu controller sends the message canPerformAction:withSender: to its view, which returns YES or NO depending on whether the view implements this method.

If you want to implement one of these methods but do not want it to appear in the menu, you can override canPerformAction:withSender: to return NO.

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender

{

if (action == @selector(copy:))

return NO;

// The superclass's implementation will return YES if the method is in the .m file

return [super canPerformAction:action withSender:sender];

}

For the More Curious: More on UIGestureRecognizer

We have only scratched the surface of UIGestureRecognizer; there are more subclasses, more properties, and more delegate methods, and you can even create recognizers of your own. This section will give you an idea of what UIGestureRecognizer is capable of, and then you can study the documentation for UIGestureRecognizer to learn even more.

When a gesture recognizer is on a view, it is really handling all of the UIResponder methods, like touchesBegan:withEvent:, for you. Gesture recognizers are pretty greedy, so they typically do not let a view receive touch events or they at least delay the delivery of those events. You can set properties on the recognizer, like delaysTouchesBegan, delaysTouchesEnded, and cancelsTouchesInView, to change this behavior. If you need finer control than this all-or-nothing approach, you can implement delegate methods for the recognizer.

At times, you may have two gesture recognizers looking for very similar gestures. You can chain recognizers together so that one is required to fail for the next one to start using the method requireGestureRecognizerToFail:.

One thing you must understand to master gesture recognizers is how they interpret their state. Overall, there are seven states a recognizer can enter:

· UIGestureRecognizerStatePossible

· UIGestureRecognizerStateBegan

· UIGestureRecognizerStateChanged

· UIGestureRecognizerStateEnded

· UIGestureRecognizerStateFailed

· UIGestureRecognizerStateCancelled

· UIGestureRecognizerStateRecognized

Most of the time, a recognizer will stay in the possible state. When a recognizer recognizes its gesture, it goes into the began state. If the gesture is something that can continue, like a pan, it will go into and stay in the changed state until it ends. When any of its properties change, it sends another message to its target. When the gesture ends (typically when the user lifts the finger), it enters the ended state.

Not all recognizers begin, change, and end. For gesture recognizers that pick up on a discrete gesture like a tap, you will only ever see the recognized state (which has the same value as the ended state).

Finally, a recognizer can be cancelled (by an incoming phone call, for example) or fail (because no amount of finger contortion can make the particular gesture from where the fingers currently are). When these states are transitioned to, the action message of the recognizer is sent, and the state property can be checked to see why.

The three built-in recognizers you did not implement in this chapter are UIPinchGestureRecognizer, UISwipeGestureRecognizer, and UIRotationGestureRecognizer. Each of these have properties that allow you to fine-tune their behavior. The documentation will show you the way.

Finally, if there is a gesture you want to recognize that is not implemented by the built-in subclasses of UIGestureRecognizer, you can subclass UIGestureRecognizer yourself. This is an intense undertaking and outside the scope of this book. You can read the Subclassing Notes in theUIGestureRecognizer documentation to learn what is required.

Silver Challenge: Mysterious Lines

There is a bug in the application. If you tap on a line and then start drawing a new one while the menu is visible, you will drag the selected line and draw a new line at the same time. Fix this bug.

Gold Challenge: Speed and Size

Piggy-back off of the pan gesture recognizer to record the velocity of the pan when you are drawing a line. Adjust the thickness of the line being drawn based on this speed. Make no assumptions about how small or large the velocity value of the pan recognizer can be. (In other words, log a variety of velocities to the console first.)

Mega-Gold Challenge: Colors

Have a three-finger swipe upwards bring up a panel that shows some colors. Selecting one of those colors should make any lines you draw afterwards appear in that color. No extra lines should be drawn by putting up that panel – or at least any lines drawn should be immediately deleted when the application realizes that it is dealing with a three-finger swipe.