Recently, I wanted to implement an interface where a user holds down on a UIView class or subclass to reveal a copy menu. Basically, I didn’t want to have to present the user with a UITextField
just to provide the ability to copy some data to the pasteboard. In this case, I’m working with a UILabel, but a similar paradigm already exists in many Apple-supplied apps where one can hold down on an image and be presented with the Copy menu option.
Going in it seemed pretty straight-forward, but it ended up taking me the better part of an afternoon of trial and error alongside the Event Handling section of iPhone Application Programming Guide to work it all out, so I believe a tutorial is in order. A reference project with sample code is available on Github.
One can easily calculate the length of time a touch was held when the touch ends (by calculating the difference between the timestamp
properties of the passed UIEvent
and UITouch
objects), but I found making this the point of responding to the event less than ideal because it means responding to the user’s interaction after the user lifts her finger, rather than while she is holding down on the screen. I’d rather respond while the user is still holding her finger down to let her know that the instruction was received. If the software will only respond after the user lifts her finger, she has no idea how long she has to hold her finger down, which is a nuisance, really.
Old Cocoa pros and experienced UIKitters probably saw the solution from a mile away: we intercept the touches began event for the view we’re interested in, and tell some object to do something after a long enough delay (the minimum time we want a user to have to hold to do something). We then cancel the request if any of the other touch events fire before our delay hits. That looks something like this, depending on your needs:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *t = [touches anyObject];
if ([t locationInView:someViewWeAreInterestedIn])
[self performSelector:@selector(showMenu) withObject:nil afterDelay:0.8f];
}
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(showMenu) object:nil];
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(showMenu) object:nil];
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(showMenu) object:nil];
}
There may be better ways to do this, but this seems pretty solid. In the sample code you can see this at work in the view controller, which shows a hidden image once a user holds down on another image for long enough. Just under a second (0.8s) seemed to feel right to me.
- (void)holdingView:(id)view {
[hiddenView setHidden:NO];
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
NSSet *imageTouches = [event touchesForView:imageView];
if ([imageTouches count] > 0) {
[self performSelector:@selector(holdingView:) withObject:imageView afterDelay:0.8f];
}
[super touchesBegan:touches withEvent:event];
}
I feel like there’s real pun-potential for this subtitle, but reasonably groan-inducing text is eluding me. In any event, now that we can detect when a user has held our view long enough to warrant a response, we need to make a move: presenting the UIMenuController
with the Copy option and actually copying something in response. I’m sure there are various approaches that can be taken, but my approach was to start by subclassing UILabel
, curious to hear other ideas.
First, I wired the subclass to intercept touch events, and to save that touch-down for the extra point (ho!):
- (BOOL)canBecomeFirstResponder {
return YES;
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
if ([self canBecomeFirstResponder]) {
[self becomeFirstResponder];
UITouch *t = [touches anyObject];
holdPoint = [t locationInView:self];
[self performSelector:@selector(showMenu) withObject:nil afterDelay:0.8f];
}
}
// (other touches* methods implemented to cancel perform) ...
Showing the menu itself is a touch awkward, you need to provide a “target rectangle” (CGRect
) to UIMenuController
to tell it about where on the screen you want the menu to appear (it can appear above or below this point, depending on proximity to the screen bounds).
- (void)showMenu {
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self selector:@selector(reset) name:UIMenuControllerWillHideMenuNotification object:nil];
// bring up editing menu.
UIMenuController *theMenu = [UIMenuController sharedMenuController];
CGRect myFrame = [[self superview] frame];
CGRect selectionRect = CGRectMake(holdPoint.x, myFrame.origin.y - 12.0, 0, 0);
[self setNeedsDisplayInRect:selectionRect];
[theMenu setTargetRect:selectionRect inView:self];
[theMenu setMenuVisible:YES animated:YES];
// do a bit of highlighting to clarify what will be copied, specifically
_bgColor = [self backgroundColor];
[_bgColor retain];
[self setBackgroundColor:[UIColor blackColor]];
}
Note that I’m registering for a notification: I basically wanted to know whenever the menu disappeared, because that would mean it’s time to stop high-lighting the text in the label, and restore the original background color. Totally not required for getting the menu on screen.
Next we have to make it clear to the UIMenuController that we mean serious business, and what kind of business we intend to conduct. In my case, I was only interested in Copy, but other options are available:
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
BOOL answer = NO;
if (action == @selector(copy:))
answer = YES;
return answer;
}
And in my case, the data I’m looking to copy is simply the text of the label itself, and I just want to put it on the general pasteboard so the user can paste it into another app, or wherever:
- (void)copy:(id)sender {
UIPasteboard *gpBoard = [UIPasteboard generalPasteboard];
[gpBoard setValue:[self text] forPasteboardType:@"public.utf8-plain-text"];
}
That’s it!