UIKit sets first responder to nil, making it impossible to send -undo: (and other actions) to appropriate targets

In our applications, we have an Undo button in our main view controller’s navigation bar. This button is supposed to do two things:

  • If there is a text field being edited, it should undo in the text field’s undo stack.

  • Otherwise, it should undo in the document’s undo stack.

Unfortunately, dismissing the keyboard clears out the window’s first responder, which means the -undo: message never makes it to the view controller.

The simplest improvement would be for -resignFirstResponder to walk the responder chain from the receiver until it found a responder that returned YES from -becomeFirstResponder. That would at least keep the first responder in a more sensible steady-state.

More complicated scenarios might demand a more involved solution. For example, a text field might itself be nested within a view that itself returns YES from -becomeFirstResponder. The superview might do this by sending -becomeFirstResponder to the very same text field. This is a common pattern on the Mac, but given the above scheme it will cause an inescapible loop when the text field tries to dismiss its keyboard.

One response is “don’t do that” — that when sent -becomeFirstResponder, a responder should either become first responder or not, but should not attempt to make some other object first responder. This solution has merit and should not be discounted outright.

Alternatively, there could be some first responder mediation logic. Instead of sending -becomeFirstResponder immediately, -resignFirstResponder could first send -canBecomeFirstResponder up the responder chain, looking for the first object to return YES. It could then send a method like -shouldResponder:becomeFirstResponderForResigningFirstResponder: up the chain, and if anyone says NO, try again with the next object to return YES from -canBecomeFirstResponder. If -shouldResponder:becomeFirstResponder… falls off the end of the responder chain without anyone returning NO, then the proposed object is sent -becomeFirstResponder. If it returns NO, start over from the next object that returned YES from -canBecomeFirstResponder.

Here’s a terribly-inefficient O(N^2) implementation of that algorithm:

- (BOOL)resignFirstResponder {

  // Easy case: we're not first responder, so we've resigned it successfully.

  if (!self.isFirstResponder)
    return YES;

  // Find the next object in the responder chain that is willing to be come first responder.

  for (UIResponder *candidate = self.nextResponder; candidate != nil; candidate = candidate.nextResponder) {

    if (!candidate.canBecomeFirstResponder)
      continue;

    // Ask every responder in the chain, starting with ourselves, if it's ok if the candidate becomes first responder.
    // Note that this includes any responders whom we've offered to become first responder but have refused, _and_ any responders in the chain _after_ the candidate.
    // Most likely, responders will only say NO if asked about themselves or objects they control (like subviews, or views managed by a view controller). This is why we ask the entire responder chain, rather than starting or ending it at the candidate.
    // The default implementation of -shouldResponder:... just returns YES.

    BOOL candidateIsOK = YES;
    for (UIResponder *consultant = self; consultant != nil; consultant = consultant.nextResponder) {
      if ([consultant shouldResponder:candidate becomeFirstResponderForResigningFirstResponder:self]) {
        candidateIsOK = NO;
        break;
      }
    }

    if (!candidateIsOK)
      continue; // Some responder explicitly vetoed our proposed candidate.

    // Everyone's okay with it; ask the responder itself to -becomeFirstResponder!
    // It's possible for a responder to return YES from -shouldResponder:becomeFirstResponder... given itself as an argument, but then return NO from -becomeFirstResponder. We will accommodate such sadists.

    BOOL candidateBecameFirstResponder = [candidate becomeFirstResponder];
    if (candidateBecameFirstResponder)
      return YES;
    }
  }

  // Nobody became first responder. Give up?
  return NO;
}

Filed as <rdar://problem/19064733>.