Responding to the Tab and Shift + Tab Keys on iOS 5 & iOS 6, with an External Keyboard

Today I want to solve a problem a lot of people have seemingly given up on.

Innumerable iOS developers have discovered that it is easy to implement field advancement for the iPhone and iPad, so long as it is based on the Return key, by implementing the UITextFieldDelegate method textFieldShouldReturn:. But they also discovered that it’s impossible to alter, and frustrating to experience, how iOS responds to the Tab key.

Most users don’t have a tab key, but all developers have a tab key in their iOS Simulator. Much of the time (if you stick to using UITableViews with editable cells), it will work fine. But if you stray away from single UITableViews of cells, you’re going to have a bad day.

Let me give you a quick example: I have four UITextFields on screen for entering simple pieces of data, and they all use my RootViewController as their UITextFieldDelegate. Every time I demo my app from the simulator I hit the tab key to advance my fields without thinking, and I’m tired of it!

Here is what happens.

Tab Order (Expected):
  • First
  • Second
  • Third
  • Fourth

Obvious, right?

Tab Order (Actual):
  • First
  • Fourth
  • Second
  • Third

So frustrating.

If you’ve been in my shoes, you have by now figured out that there is no way to directly access the keys that a user is typing via iOS, unlike AppKit. If you debug deep enough you’ll notice that all key press events become GSEvents whose implementation is private, and if you tried to access them successfully Apple would likely reject your app. GSEvents are totally hidden and undocumented, and there’s not even a hint of API guidance for responding appropriately to hardware keyboards at all. Nothing in UITextInputDelegate seems to provide any access to the tab key, let alone shift+tab, yet UIKit clearly knows! It works in Safari and it works in Apple’s contacts App, and it works when you stick to plain UITableViews.

This is very inconvenient, since many users use external bluetooth keyboards and it makes us developers look bad when our apps misbehave for reasons beyond our control.

What I’m going to do now is show you how to define an arbitrary tab order for iOS fields by exploiting knowledge of the underlying implementation of tab key input.

First let’s do something easy and handle textFieldShouldReturn:, just in case you’ve never run across this before. People on Stack Overflow like tagging views with integers, but I like arrays. In my case the fact that my views are retained by the array is not significant; in your case it might matter.

- (void)viewDidLoad {
    [super viewDidLoad];
    self.viewOrder = @[self.firstField, self.secondField, self.thirdField, self.fourthField];
    self.uikitObservedOrder = @[self.firstField, self.fourthField, self.secondField, self.thirdField];
    self.currentTextField = nil;
}

-(BOOL)textFieldShouldReturn:(UITextField*)textField {
    if ([self.viewOrder containsObject:textField]) {
        UIResponder *nextField = [self.viewOrder objectAtIndex:([self.viewOrder indexOfObject:textField] + 1) % self.viewOrder.count];
        [nextField becomeFirstResponder];
    }
    else {
        // Unknown field, just resign first responder.
        [textField resignFirstResponder];
    }
    return NO;
}

OK, now we have a simple method that will advance the current responder in a manner of our choosing when they press the return key, and wrap around to the first element from the last element. In fact, if we had other UIResponders besides these text views, we could add them into the viewOrder array to specify the tab order across the whole view controller. Now let’s handle tabs.

Through my own research, I discovered that when you press the tab key:

  1. UIKit rapidly queries all of the text fields on screen with textFieldShouldBeginEditing: to see which fields can be tabbed into.  (Update: It also queries the hidden status of the fields and will notice if they are hidden to exclude them from step 2.)
  2. UIKit chooses a target responder with some non-obvious, nonvolatile internal algorithm and repeats the query to the target responder.
  3. If the target responds YES again, then the target field begins editing.
  4. If the target responds NO the second time, then nothing happens as a result of the tab but you enter a new state where tabs are restricted.
  5. If tabs are restricted, then when you tab UIKit will still query all your fields to see if they are restricted, but it’s only looking for the Target Field to return YES. The results of the other queries appear to be discarded.

My methodology is straightforward:

  1. I keep track of the calls to textFieldShouldBeginEditing: and almost always return YES.
  2. I only return NO when I know that UIKit has examined all of my fields once without any text field entering edit mode, and only if UIKit’s arbitrary order didn’t already pick the field that I want UIKit to pick.

More specifically, by examining which target field UIKit picked and comparing that against the observed arbitrary tab order, I can determine whether the fields are advancing forwards (tab) or backwards (shift + tab). I then call becomeFirstResponder on the next or previous field from the currently selected field. (I also clear up any tracking data immediately before calling becomeFirstResponder, to avoid going infinitely recursive.) Whenever textFieldDidBeginEditing: is called, I also make sure to clear all my tracking data.

static int flags = 0; // Arbitrary method of keeping track of a few fields
- (void)recordTextFieldEntered:(UITextField *)textField {
    // If all four fields haven't been visited, mark the current field as visited.
    if (flags != 0xf) {
        int flag = 1 << [self.viewOrder indexOfObject:textField];
        flags |= flag;
        self.uikitExpectedField = nil;
    }
    // Else we have recorded the field UIKit expects
    else {
        self.uikitExpectedField = textField;
    }
}

- (BOOL)didBypassUIKitTab {
    if (self.uikitExpectedField) {
        int advancement = [self.uikitObservedOrder indexOfObject:self.uikitExpectedField] - [self.uikitObservedOrder indexOfObject:activeField];
        advancement += self.uikitObservedOrder.count; // Just to make sure it's a positive number

        UITextField *tabbedTextField = [self.viewOrder objectAtIndex:([self.viewOrder indexOfObject:activeField] + advancement) % self.viewOrder.count];
        if (self.uikitExpectedField != tabbedTextField) {
            flags = 0;
            self.uikitExpectedField = nil;
            [tabbedTextField becomeFirstResponder];
            return YES;
        }
    }
    return NO;
}

- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField {
    [self recordTextFieldEntered:textField];
    BOOL bypassed = [self didBypassUIKitTab];
    return !bypassed;
}

- (void)textFieldDidBeginEditing:(UITextField *)textField {
    flags = 0;
    self.uikitExpectedField = nil;
    activeField = textField;
}

I tried this out and it worked perfectly on both iOS 6 and iOS 5.1, in the simulator and the device. It is perfectly extensible to other types of UIResponders, though the choice of using integer flags to keep track of what’s been added is weak. Adding to an NSMutableSet would be a better alternative and could handle any number of fields.

Hope this works for you!

[Update]

One little addendum. UIKit skips hidden/unavailable fields and your implementation of didBypassUIKitTab should skip hidden fields as well. This is accomplished with a for loop, testing the fields in the order of advancement until you find one that isn’t hidden. If you don’t find a visible field at all (unlikely, but who knows!), you should probably reset everything and resign first responder status.

Advertisements

One thought on “Responding to the Tab and Shift + Tab Keys on iOS 5 & iOS 6, with an External Keyboard

  1. Great post! I had to add  [textField resignFirstResponder]; under textFieldShouldReturn so the tableView I have would scroll up as the responder changed. Other than that, it worked for me.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s