Technology Musings

May 29, 2013

Snippets / Adding a "Copy" context menu to a disabled UITextfield in iOS

JB

I have a project which has a lot of fields which, at any moment, may be enabled or disabled.  However, even when they are disabled, I want to show a context menu.  It turns out that no one has a solution, so I hacked one together for myself.  This code is adapted from a working project, but I have not tested the version presented here, but let me know if you have problems.  

The general problem you run into is this: if your UITextField is disabled, then it cannot receive touch events.  Therefore, it cannot fire the menu, even if you try to do so explicitly using Gesture recognizers.  The normal answer is to simply not allow any editing on the field.  However, while this prevents you from editing the field, it still *draws* the textfield as active.  We want it to *look* inactive as well, which you only get by disabling it.

So, what I did was to create a subclass of UITextField (JBTextField) which has a subview (a specialized subclass of UIView - JBTextFieldResponderView) that has a gesture recognizer attached.  We override hitTest on JBTextField to do this: if JBTextField is disabled, then we delegate hitTest to our JBTextFieldResponderView subclass.

As I said, this is from another project, which had a whole bunch of other stuff mixed in.  I have tried to separate out the code for you here, but I don't guarantee that it will work out-of-the-box.

@interface JBTextFieldResponderView : UIView
@property (strong, nonatomic) UIGestureRecognizer *contextGestureRecognizer;
@end

@interface JBTextField : UITextField
@property (strong, nonatomic) JBTextFieldResponderView *contextGestureRecognizerViewForDisabled;
@end
@implementation JBTextFieldResponderView
@synthesize contextGestureRecognizer = _contextGestureRecognizer;

-(id) initWithFrame:(CGRect)frame {
  self = [super initWithFrame:frame];

  // Add the gesture recognizer
  self.contextGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGestureDidFire:)];
  [self addGestureRecognizer:self.contextGestureRecognizer];

  return self;
}

// First-level of response, filters out some noise
-(IBAction) longPressGestureDidFire:(id)sender {
  UILongPressGestureRecognizer *recognizer = sender;
  if(recognizer.state == UIGestureRecognizerStateBegan) { // Only fire once
    [self initiateContextMenu:sender];
  }
}

// Second level of response - actually trigger the menu
-(IBAction) initiateContextMenu:(id)sender {
  [self becomeFirstResponder]; // So the menu will be active.  We can't set the Text field to be first responder -- doesn't work if it is disabled
  UIMenuController *menu = [UIMenuController sharedMenuController];
  [menu setTarget:self.bounds inView:self];
  [menu setMenuVisible:YES animated:YES];
}

// The menu will automatically add a "Copy" command if it sees a "copy:" method.  
// See UIResponderStandardEditActions to see what other commands we can add through methods.
-(IBAction) copy:(id)sender {
  UIPasteboard *pb = [UIPasteboard generalPasteboard];
  UITextField *tf = (UITextField *)self.superview;
  [pb setString: tf.text];
}

// Normally a UIView doesn't want to become a first responder.  This forces the issue.
-(BOOL) canBecomeFirstResponder {
  return YES;
}

@end

@implementation JBTextField
@synthesize contextGestureRecognizerViewForDisabled = _contextGestureRecognizerViewForDisabled;

/* Add the recognizer view no matter which path this is initialized through */

-(id) initWithCoder:(NSCoder *)aDecoder {
  self = [super initWithCoder:aDecoder];
  [self addDisabledRecognizer];
  return self;
}

-(id) initWithFrame:(CGRect)frame {
  self = [super initWithFrame:frame];
  [self addDisabledRecognizer];
  return self;
}

// This creates the view and adds it at the end of the view chain so it doesn't interfere, but is still present */
-(void) addDisabledRecognizer {
  self.contextGestureViewForDisabled = [[JBTextFieldResponderView alloc] initWithFrame:self.bounds];
  self.contextGestureViewForDisabled.userInteractionEnabled = NO;
  [self addSubview:self.contextGestureViewForDisabled];
}

-(void) setEnabled:(BOOL) enabled {
  [super setEnabled:enabled];
  self.contextGestureViewForDisabled.userInteractionEnabled = !enabled;
}

// This is where the magic happens
-(UIView *) hitTest:(CGPoint)point withEvent:(UIEvent *)event {
  if(self.enabled) {
    // If we are enabled, respond normally
    return [super hitTest:point withEvent:event];
  } else {
    // If we are disabled, let our specialized view determine how to respond
    UIView *v = [self.contextGestureViewForDisabled hitTest:point withEvent:event];
  }
}

@end

Now, in interface builder, we can enabled copy on disabled fields just by setting the class to JBTextField, or, if built programmatically, by replacing [UITextField alloc] with [JBTextField alloc].

I hope this helps!

Additional Resources: