27

I am developing an iPad app that has a large number of UIViewControllers, UITableViews (with cells with accessoryViews of UITextFields) etc, etc. Many of the UIViewControllers appear within a navigation hierarchy.

There are many different places where UITextFields appear, including as UITableViewCell accessoryViews.

I would like to devise an efficient strategy for dismissing the keyboard whenever the user touches outside the UITextField currently being edited. I have searched for keyboard dismiss techniques but have not yet found an answer that explains how a general keyboard dismiss strategy might work.

For example, I like this approach, where the following code is added to any ViewController:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSLog(@"* * * * * * * * *ViewControllerBase touchesBegan");

    [self.view endEditing:YES]; // dismiss the keyboard

    [super touchesBegan:touches withEvent:event];
}

...but this technique does not deal with situations where, for example, a touch occurs within a UITableView which is on display. So, I'd need to add some code to call endEditing when a UITableView is touched etc, etc. Which means that my app will be liberally sprinkled with lots of code to dismiss the keyboard when various other UIElements are touched.

I guess I could just try and identify all the different places where touches need to be intercepted and the keyboard dismissed, but it seems to me that there may be a better design pattern somewhere for handling iOS keyboard dismiss events.

Can anyone share their experiences in this matter, and recommend a specific technique for generically handling keyboard dismissal across an entire app?

Many thanks

Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
whatdoesitallmean
  • 1,586
  • 3
  • 18
  • 40

8 Answers8

25

Your view hierarchy lives inside a UIWindow. The UIWindow is responsible for forwarding touch events to the correct view in its sendEvent: method. Let's make a subclass of UIWindow to override sendEvent:.

@interface MyWindow : UIWindow
@end

The window will need a reference to the current first responder, if there is one. You might decide to also use UITextView, so we'll observe notifications from both text fields and text views.

@implementation MyWindow {
    UIView *currentFirstResponder_;
}

- (void)startObservingFirstResponder {
    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
    [center addObserver:self selector:@selector(observeBeginEditing:) name:UITextFieldTextDidBeginEditingNotification object:nil];
    [center addObserver:self selector:@selector(observeEndEditing:) name:UITextFieldTextDidEndEditingNotification object:nil];
    [center addObserver:self selector:@selector(observeBeginEditing:) name:UITextViewTextDidBeginEditingNotification object:nil];
    [center addObserver:self selector:@selector(observeEndEditing:) name:UITextViewTextDidEndEditingNotification object:nil];
}

- (void)stopObservingFirstResponder {
    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
    [center removeObserver:self name:UITextFieldTextDidBeginEditingNotification object:nil];
    [center removeObserver:self name:UITextFieldTextDidEndEditingNotification object:nil];
    [center removeObserver:self name:UITextViewTextDidBeginEditingNotification object:nil];
    [center removeObserver:self name:UITextViewTextDidEndEditingNotification object:nil];
}

- (void)observeBeginEditing:(NSNotification *)note {
    currentFirstResponder_ = note.object;
}

- (void)observeEndEditing:(NSNotification *)note {
    if (currentFirstResponder_ == note.object) {
        currentFirstResponder_ = nil;
    }
}

The window will start observing the notifications when it's initialized, and stop when it's deallocated:

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

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

- (void)commonInit {
    [self startObservingFirstResponder];
}

- (void)dealloc {
    [self stopObservingFirstResponder];
}

We'll override sendEvent: to “adjust” the first responder based on the event, and then call super's sendEvent: to send the event normally.

- (void)sendEvent:(UIEvent *)event {
    [self adjustFirstResponderForEvent:event];
    [super sendEvent:event];
}

We don't need to do anything about the first responder if there is no first responder. If there is a first responder, and it contains a touch, we don't want to force it to resign. (Remember, there can be multiple touches simultaneously!) If there is a first responder, and a new touch appears in another view that can become the first responder, the system will handle that correctly automatically, so we also want to ignore that case. But if there is a first responder, and it doesn't contain any touches, and a new touch appears in a view that can't become first responder, we want to make the first responder resign.

- (void)adjustFirstResponderForEvent:(UIEvent *)event {
    if (currentFirstResponder_
        && ![self eventContainsTouchInFirstResponder:event]
        && [self eventContainsNewTouchInNonresponder:event]) {
        [currentFirstResponder_ resignFirstResponder];
    }
}

Reporting whether an event contains a touch in the first responder is easy:

- (BOOL)eventContainsTouchInFirstResponder:(UIEvent *)event {
    for (UITouch *touch in [event touchesForWindow:self]) {
        if (touch.view == currentFirstResponder_)
            return YES;
    }
    return NO;
}

Reporting whether an event contains a new touch in a view that can't become first responder is almost as easy:

- (BOOL)eventContainsNewTouchInNonresponder:(UIEvent *)event {
    for (UITouch *touch in [event touchesForWindow:self]) {
        if (touch.phase == UITouchPhaseBegan && ![touch.view canBecomeFirstResponder])
            return YES;
    }
    return NO;
}

@end

Once you've implemented this class, you need to change your app to use it instead of UIWindow.

If you're creating your UIWindow in application:didFinishLaunchingWithOptions:, you need to #import "MyWindow.h" at the top of your AppDelegate.m, and then change application:didFinishLaunchingWithOptions: to create a MyWindow instead of a UIWindow.

If you're creating your UIWindow in a nib, you need to set the custom class of the window to MyWindow in the nib.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Thanks very much for a very comprehensive answer. – whatdoesitallmean Jul 04 '12 at 10:57
  • Any ways I can intercept the event inside one view controller to prevent the general behaviour set in the MyWindow above? Only at one view controller, i need to keep my keyboard appear all the time. – Mickey Cheong Dec 22 '12 at 18:26
  • You could walk up the responder chain, starting with `currentFirstResponder_`, asking each responder whether it wants to prevent the special handling. – rob mayoff Dec 22 '12 at 21:45
  • 1
    I noticed that the cancel (remove text) button stopped working after implementing this solution. If you want this behavior back, then edit this line in `eventContainsTouchInFirstResponder` : `if (touch.view == currentFirstResponder_ || touch.view.superview == currentFirstResponder_) return YES;` – polyclick Jan 22 '13 at 11:14
  • Thanks rob. I want touches outside the keyboard or (in this case) search bar to dismiss the keyboard and NOT be passed through to the underlying view. Therefore, I moved `[super sendEvent:event];` to an `else` in `adjustFirstResponderForEvent:`. It seems to work EXCEPT that touching the little x in the search bar (or several pixels left of it) simply dismisses the keyboard (w/o clearing the text); I suppose that area is not part of the first responder, so due to my change the touch simply resigns the first responder. Any suggestions on how to fix that? Thanks! – mkc842 Oct 09 '13 at 15:05
  • Bloody brilliant. Works! Swift version: https://gist.github.com/d2338f673b2fc80455a4 –  May 30 '15 at 10:33
  • If you use storyboard, to add this CustomWindow in your App you'll need to add this on AppDelegate: - (UIWindow *)window{ static CustomWindow *customWindow = nil; if (!customWindow) customWindow = [[CustomWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; return customWindow; } – Elvis Oliveira Jul 03 '15 at 23:52
23

Here is a much easier and efficient way of dealing with that. This is gonna work for any UITextField in your view controller. You can even add it to your base view controller (if you have got one) and it will work like a charm.

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {

    UITouch *touch = [[event allTouches] anyObject];

    if (![[touch view] isKindOfClass:[UITextField class]]) {
        [self.view endEditing:YES];
    }
    [super touchesBegan:touches withEvent:event];
}
Yas Tabasam
  • 10,517
  • 9
  • 48
  • 53
  • this will just works for the view. If you have other controls on that view, like textViews, buttons, segmentedControls, etc., it will not work. – Duck Jun 11 '14 at 03:40
  • @RubberDuck - Not sure what you mean by it works only on `view`, can you please elaborate as I have it working as intended in all of my `UIViewController`s? – Yas Tabasam Jun 11 '14 at 15:35
  • I added this inside my view controller but nothing happens when I click outside, could you elaborate on how to implement this? seems like by far the easiest answer. – John Mar 25 '15 at 00:03
  • I am not seeing this called at all in my UITableViewController subclass when I tap on any cells of the table view. Running iOS 8.2. – Greg Feb 01 '16 at 20:10
3

I normally use the following

First:

Include the following extension in your implementation file, findFirstResponder will help you find the first resonder

@implementation UIView (FindFirstResponder)
- (UIView*)findFirstResponder
{
    if (self.isFirstResponder) {
        return self;     
    }
    for (UIView *subView in self.subviews) {
        UIView *responder = [subView findFirstResponder];
        if (responder)
            return responder;
    }

    return nil;
}
@end

Then in your view controller viewDidLoad add the following

- (void)viewDidLoad
{
    [super viewDidLoad];
    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
    [center addObserver:self selector:@selector(keyboardDidShow:) 
                   name:UIKeyboardDidShowNotification 
                 object:nil];

    [center addObserver:self selector:@selector(keyboardDidHide:) 
                   name:UIKeyboardWillHideNotification 
                 object:nil];
    // Do any additional setup after loading the view, typically from a nib.
}

The notification functions will be like this

- (void) keyboardDidShow:(NSNotification*)notification
{    
    UIButton *button = [[UIButton alloc] init];

    CGRect rect = self.view.bounds;


    button.frame = rect;
    button.backgroundColor = [UIColor blackColor];
    button.tag = 111;
    UIView *currentResponer = [self.view findFirstResponder];
    [button addTarget:currentResponer action:@selector(resignFirstResponder) forControlEvents:UIControlEventTouchUpInside];

    [self.view insertSubview:button belowSubview:currentResponer];
}

- (void) keyboardDidHide:(NSNotification*)notification
{
    [[self.view viewWithTag:111] removeFromSuperview];
}

When the keyboard shows, i add a UIButton beneath the current first responder, this button action will hide the keyboard,

A limitation here is that the UITextField has to be in self.view however you could adapt this technique for your need with some modifications, hope it helps you

Omar Abdelhafith
  • 21,163
  • 5
  • 52
  • 56
2

I actually really like Omar's technique - it works beautifully - but I've added a couple of lines in my case.

- (void)keyboardDidShow:(NSNotification*)notification
{
    UIButton *button = [[UIButton alloc] init];

    CGRect rect = self.view.bounds;

    button.frame = rect;
    button.backgroundColor = [UIColor blackColor];

    [button setAlpha:0.5f];
    button.tag = 111;
    UIView *currentResponder = [self.view findFirstResponder];

    [self.view bringSubviewToFront:currentResponder];

    if (currentResponder == _descriptionTextView) [_descriptionTextView setTextColor:[UIColor whiteColor]];

    [button addTarget:currentResponder action:@selector(resignFirstResponder) forControlEvents:UIControlEventTouchUpInside];

    [self.view insertSubview:button belowSubview:currentResponder];
}

I've added three lines to his solution (see above):

  1. Set the alpha of the button to 0.6, so that I can see some of my viewController behind it.
  2. Bring the currentResponder to the front, so that none of my other views are in front of the button.
  3. Switch the colour of my UITextView so that the text is clearer (my UITextView has a clear background, so the text wasn't showing clearly).

Anyway, only minor changes, but hopefully these help. Thanks Omar.

siburb
  • 4,880
  • 1
  • 25
  • 34
1

To resign the current first responder, I tried using endEditing, but had some issues setting a first responder afterwards. I then used a recursive method for awhile to find the first responder in a UIView category method:

- (UIView *)getFirstResponder {

    if (self.isFirstResponder) {
        return self;
    }

    for (UIView *subView in self.subviews) {
        UIView *firstResponder = [subView getFirstResponder];

        if (firstResponder != nil) {
            return firstResponder;
        }
    }

    return nil;
}

Once the responder is returned, then I can just call resignFirstResponder on it.

Just today though, a buddy of mine at work showed me an even better way here: http://overooped.com/post/28510603744/i-had-completely-forgotten-about-nil-targeted

Just call this:

[[UIApplication sharedApplication] sendAction:@selector(resignFirstResponder) to:nil from:nil forEvent:nil];

Awesome one liner!

Matt Becker
  • 2,338
  • 1
  • 28
  • 36
1

In Swift 3

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {

    self.view.endEditing(true)
}
Gagandeep Gambhir
  • 4,225
  • 1
  • 29
  • 34
0

May be I don't understands what you want properly but - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch method may help you for dismissing the keyboard

The iOSDev
  • 5,237
  • 7
  • 41
  • 78
0

Add a "textFieldDidChange" notification method to the text field control.

[textField addTarget:self action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingDidEnd];

works for me

alvarodoune
  • 921
  • 10
  • 13