-------------------------------------------------
| To: (token1) (token2) (token3) _____________ |
-------------------------------------------------
^ ^ ^ ^ ^
UILabel tokens UITextField
In Mail.app, if you're at the far left of the text field and hit the backspace key, the rightmost token will be highlighted. Another backspace deletes the token and puts focus back in the text field. But there's a problem.
UITextField provides no way to detect a backward delete at the beginning of the field.Part 1: Discovery
You'd think this would be simple, but it's not. Some of my early attempts included:
- Implementing
-textField:shouldChangeCharactersInRange:replacementString:in the text field's delegate and looking for an attempt change the range at (0,0). No dice—it's not called if the text isn't actually going to change. - Subclassing
UITextFieldand overriding the-deleteBackwardmethod (part of theUIKeyInputprotocol, whichUITextFieldconforms to viaUITextInput).-[UITextField deleteBackward]is never called when the backspace key is pressed. - Subclassing
UITextFieldand overriding various methods declared in theUITextInputprotocol, such as-replaceRange:withText:,-setSelectedTextRange:, etc. Surprisingly, none of these are called either—despite declaring conformance to theUITextInputprotocol, it seems that UIKit doesn't actually use any of those methods when handling keyboard input.
UITextField that claims to implement all these text-handling methods, and yet it doesn't seem to use them when handling keyboard input. It's probably forwarding them on to some other object, but how could we find out who that other object is?DTrace, of course.
Using a boilerplate app containing a single UITextField, I pulled out Instruments and added a Trace instrument for
-[* delete*].That is, "Trace every ObjC call to any object where the selector starts with the word 'delete'." I launched the app and waited for things to quiet down, then tapped in the text field and hit the backspace key on the keyboard. Immediately, a few delete-related methods showed up, including this stack trace:
Why hello there,
UIFieldEditor with a UITextInputAdditions category. We've been looking for you.I love DTrace.
Part 2: Digging through the dump
Okay, so we know there's something called a
UIFieldEditor, and since UITextField wasn't involved at all in the above stack trace, we can guess that the field editor may be doing all of the heavy lifting. So let's look at UIFieldEditor a bit deeper.If we class-dump
UIFieldEditor in UIKit, we see that the first three methods in its method list are these:+ (void)releaseSharedInstance;
+ (id)sharedFieldEditor;
+ (id)activeFieldEditor;
So it appears that the
UIFieldEditor receiving keyboard input is likely a shared object that is reused whenever the first responder needs keyboard input. Since there can only be one first responder at a time, there's probably no need for more than one UIFieldEditor.Class-dump also tells us that
UIFieldEditor is a subclass of UIWebDocumentView. This is interesting, because a bit of time in DTrace will also confirm that -[UITextField deleteBackward] calls down to [UIWebDocumentView deleteBackward]. It looks like the field editor is a view that gets overlaid on top of text input views, and probably handles most of the text editing experience.So rather than subclassing
UITextField, it looks like we really want to be subclassing UIFieldEditor if we want to do something special with -deleteBackward. Part 3: Dark Runtime Magic
We've got two problems down the "Subclass UIFieldEditor" path, though:
UIFieldEditoris a private class in UIKit, so we don't have its headers. Without the headers,@interface MyFieldEditor : UIFieldEditoris going to cause a compiler error, since it won't know how to inherit fromUIFieldEditor.- We don't control the
UIFieldEditorinstance used byUIKit. Even if we could create our ownMyFieldEditor, we still don't know how to swap out the existing shared object for our own.
UIFieldEditor at runtime and change the class of the existing field editor to be out new dynamic subclass. This sounds crazy—and it is. But it works1, and it allows us to add our own -deleteBackward functionality without having to swap out existing objects and somehow inform UIKit of what we're doing.Dynamically creating classes at runtime isn't something you're likely to do very often, but it's fairly well documented, and there have been some great posts about it in the community. I won't bother going into more detail here—it's enough to know that the dynamically-created class works just like any other, once you get it all set up. You just need to allocate a new class pair, register the new class, and add the methods we want to override to the new class. (One caveat of note: calls to
objc_allocateClassPair() and objc_registerClass() fail under ARC, so if you're using ARC you'll have to do those in a file that's not using ARC.) You can see how this works in my sample implementation, linked at the end of this post.Once we've created our
MyFieldEditor class, though, we still have to actually change the class of the existing UIFieldEditor. This requires using the runtime function object_setClass(id object, Class newClass). The newClass parameter is easy enough, but what are we going to pass it for the object? We know there's a UIFieldEditor out there, but we still don't have a reference to it.Let's go back to class-dump for a moment. Looking through the method list on
UITextField, you'll see a -(id)_fieldEditor method. Sounds like exactly what we want. Unfortunately, we can't just toss that method declaration in a category and then call it directly; that's sure to fail App Store validation for using private API. So we need some way of calling that method without making it look like we're using that method.We could probably do it with
-(id)performSelector:, but we clearly can't just create the selector with @selector(_fieldEditor); that will fail App Store validation just as quickly as calling it directly. We could construct it dynamically from a string, but ARC introduces some caveats when calling -performSelector: with a dynamically-constructed selector because it can't guarantee to get the memory management right. It would be nice to have something that would work correctly without a lot of overhead.Key-Value Coding to the rescue! Key-Value Coding is built around the idea that if you know the name of a property, Cocoa can figure out what the appropriate getters and setters should be. So, rather than trying to figure out the exact method we want to call, let's just ask Cocoa to get the fieldEditor for us:
id fieldEditor = [someTextField valueForKey:@"fieldEditor"];
It's as easy as that.
Part 4: Making the call
At this point, we have a reference to the field editor, and we've created a dynamic
UIFieldEditor subclass that we can use to customize its behavior. We never actually added any methods, though; MyFieldEditor doesn't do anything differently from UIFieldEditor yet. We'll need to dynamically add a method to MyFieldEditor, but before we can do that, we need to write the method.Our needs are actually quite simple; when
-[MyFieldEditor deleteBackward] is called, we want to call a method letting someone know that a backward deletion happened. Ideally, that "someone" would be the text field itself. Then we want to call through to the superclass implementation. Here's my implementation:
- (void)fieldEditor_deleteBackward {
MyTextField *textField = objc_getAssociatedObject(self, BackwardDeleteTargetKey);
[textField my_willDeleteBackward];
// Call through to super
Class superclass = class_getSuperclass([self class]);
SEL deleteBackwardSEL = @selector(deleteBackward);
IMP superIMP = [superclass instanceMethodForSelector:deleteBackwardSEL];
superIMP(self, deleteBackwardSEL);
}
It's really quite simple. We get a reference to the text field using ObjC associated objects, and call
-my_willDeleteBackward on it. Then we pass the -deleteBackward method up to the superclass, UIFieldEditor. We have to use the runtime methods to do the superclass call because of the dynamic subclassing game; otherwise, we'd get the wrong superclass.I'm a little nervous about unilaterally changing the behavior of
UIFieldEditor, because it seems likely that every text input area in your app uses the same instance of the field editor. So we do a little dance in MyTextField's implementations of -becomeFirstResponder and -resignFirstResponder. It looks like this:- (BOOL)becomeFirstResponder {
BOOL shouldBecome = [super becomeFirstResponder];
if (shouldBecome == NO) {
return NO;
}
Class myFieldEditorClass = objc_lookUpClass([SubclassName UTF8String]);
if (myFieldEditorClass == nil) {
myFieldEditorClass = registerMyFieldEditor();
}
id fieldEditor = [self valueForKey:@"fieldEditor"];
if (fieldEditor && myFieldEditorClass) {
object_setClass(fieldEditor, myFieldEditorClass);
objc_setAssociatedObject(fieldEditor, BackwardDeleteTargetKey,
self, OBJC_ASSOCIATION_ASSIGN);
}
return YES;
}
- (BOOL)resignFirstResponder {
BOOL shouldResign = [super resignFirstResponder];
if (shouldResign == NO) {
return NO;
}
id fieldEditor = [self valueForKey:@"fieldEditor"];
if (fieldEditor) {
objc_setAssociatedObject(fieldEditor, BackwardDeleteTargetKey,
nil, OBJC_ASSOCIATION_ASSIGN);
Class uiFieldEditorClass = objc_lookUpClass("UIFieldEditor");
if (uiFieldEditorClass) {
object_setClass(fieldEditor, uiFieldEditorClass);
}
}
return YES;
}
With this implementation, the shared
UIFieldEditor instance will only be of class MyFieldEditor while the text field is actively the first responder. As soon as the text field resigns, it goes back to being a regular old UIFieldEditor. No other text field in the app will be affected, and this text field will hear about all the backward deletion calls as soon as they come in.This is about the point where everyone working on
UIKit starts squirming vigorously. If you'd like people to not do this kind of stuff (which I'd heartily agree with), then let me direct your attention to Radars #10265826 and #10377565.In the meantime, use with caution. I haven't tried it in the App Store, but I suspect it will pass validation as there are no symbols referencing any private API in this implementation.
The code is on GitHub.
-
This is actually the same mechanism Cocoa uses to implement Key-Value Observing; when you start observing a property of some object, Cocoa generates a new subclass of that object's class and implements a setter method that wraps your own with calls to
-willSetValueForKey:and-didSetValueForKey:. When all observers on an object are gone, its class is set back to the original class. ↩


4 comments:
I'm not sure I understand why you are using a dynamic subclass instead of just swizzling -[UIFieldEditor deleteBackward]. It seems overcomplicated to me, a simple method_getImplementation + method_setImplementation or method_exchangeImplementations would be just fine IMHO.
Also, you should wrap [someTextField valueForKey:@"fieldEditor"]; inside a @try/@catch to be safe.
Swizzling feels far more fragile to me than subclassing. Even though UIFieldEditor doesn't currently appear to have any implementation of -deleteBackward (other than the inherited one), that's not an assumption I want to rely on. You can make it call the original implementation when you exchange implementations, but it makes your replacement method falsely look recursive.
In general, subclassing is the standard approach for overriding method behavior in an object-oriented paradigm, so I went with that. I also like that this approach allows me to remove all customization of the field editor when I'm done. Since the field editor is a shared object, permanently altering its behavior makes me nervous. There's no method_removeImplementation, so once you've added a method on the class, it's stuck there. (I suppose you could do the exchange implementations dance every time it becomes or resigns first responders, but that seems like a house of cards.)
In short, you probably could do it that way, but I chose not to. :)
You're absolutely right about about @try/@catch, though. I'll make that change on github.
This saved me man. Great post. Also, I didn't realize that dtrace was also useful for iOS. I had previously only used it for OSX debugging.
Yeah, DTrace is pretty great. Though I should note that it currently only works in the iOS simulator. DTrace probes on iOS devices currently fail.
Post a Comment