Caveat Formatter

September 26, 2016

NSFormatter is intended for subclassing. You know what they say, the road to Radar is paved with good intentions. A formatter is particularly useful if you need to restrict input to a text field as the user types. Below I've written a sample NSFormatter subclass that accepts only decimal digits. For the sake of brevity, I've provided simplistic implementations of the required -getObjectValue:forString:errorDescription: and -stringForObjectValue: overrides; depending on your needs, you may want NSNumber as the object instead of NSString. The purpose of this blog post is to point out a caveat with validating partial strings, so let's focus on the final method, which I'll call -isPartialStringValid: for short, because the full method signature is overly long. Also known as "Part-y" among good friends, this method isn't one of those who whine when you give them a nickname.

There's no problem if -isPartialStringValid: simply returns YES or NO. The trick is when you need to modify the selection. For example, below I deselect all text and place the insertion point at the end of any previous selection. You might do this as some kind of visual indication to the user. However, if you attempt to return the original string by reference, it doesn't work right. The new, invalid string will still appear in the text field, despite the fact that -isPartialStringValid: returns NO. By experimentation — computer science! — I discovered that you have to provide a reference to a copy of the original string rather than the original string itself. This is probably some kind of bug in AppKit. Note that if you're not using ARC, you'll need to -autorelease the -copy in order to avoid a memory leak. Failing to -autorelease a -copy or -mutableCopy is one of the most common sources of memory leaks in Objective-C code.

@import Cocoa;

@interface MyDigitFormatter : NSFormatter
@end

@implementation MyDigitFormatter

-(BOOL)getObjectValue:(id *)objectRef
            forString:(NSString *)string
	 errorDescription:(NSString **)errorDescription
{
	if ( objectRef != NULL )
		*objectRef = string;
	return YES;
}

-(NSString *)stringForObjectValue:(id)object
{
	if ( object == nil )
		return nil;
	if ( [object isKindOfClass:[NSString class]] )
		return object;
	return nil;
}

-(BOOL)isPartialStringValid:(NSString **)partialStringRef
      proposedSelectedRange:(NSRangePointer)proposedSelectedRangeRef
             originalString:(NSString *)originalString
      originalSelectedRange:(NSRange)originalSelectedRange
           errorDescription:(NSString **)errorDescription
{
	NSString *partialString = *partialStringRef;
	if ( [partialString length] == 0 )
		return YES; // Allow the user to clear the field
	
	NSScanner *scanner = [NSScanner scannerWithString:partialString];
	[scanner setCharactersToBeSkipped:nil];
	NSCharacterSet *characterSet = [NSCharacterSet decimalDigitCharacterSet];
	if ( [scanner scanCharactersFromSet:characterSet intoString:NULL] && [scanner isAtEnd] )
		return YES;
	
	// Can't simply return originalString,
	// because of AppKit bug that allows partial string through.
#if __has_feature(objc_arc)
	*partialStringRef = [originalString copy];
#else
	*partialStringRef = [[originalString copy] autorelease];
#endif
	
	NSUInteger endLocation = originalSelectedRange.location + originalSelectedRange.length;
	*proposedSelectedRangeRef = NSMakeRange( endLocation, 0 );
	
	return NO;
}

@end

If you're observant, you may have noticed that -isPartialStringValid: accepts an empty string as valid. This allows the user to clear out a text field, for instance by pressing the delete key after tabbing to the field. If an empty value is not ultimately valid for your code, you'll need to handle it when editing ends, such as in the -controlTextDidEndEditing: delegate method.