27

Need to have an NSTextField with a text limit of 4 characters maximum and show always in upper case but can't figure out a good way of achieving that. I've tried to do it through a binding with a validation method but the validation only gets called when the control loses first responder and that's no good.

Temporarly I made it work by observing the notification NSControlTextDidChangeNotification on the text field and having it call the method:

- (void)textDidChange:(NSNotification*)notification {
  NSTextField* textField = [notification object];
  NSString* value = [textField stringValue];
  if ([value length] > 4) {
    [textField setStringValue:[[value uppercaseString] substringWithRange:NSMakeRange(0, 4)]];
  } else {
    [textField setStringValue:[value uppercaseString]];
  }
}

But this surely isn't the best way of doing it. Any better suggestion?

Carlos Barbosa
  • 3,083
  • 5
  • 30
  • 30

7 Answers7

49

I did as Graham Lee suggested and it works fine, here's the custom formatter code:

UPDATED: Added fix reported by Dave Gallagher. Thanks!

@interface CustomTextFieldFormatter : NSFormatter {
  int maxLength;
}
- (void)setMaximumLength:(int)len;
- (int)maximumLength;

@end

@implementation CustomTextFieldFormatter

- (id)init {

   if(self = [super init]){

      maxLength = INT_MAX;
   }

  return self;
}

- (void)setMaximumLength:(int)len {
  maxLength = len;
}

- (int)maximumLength {
  return maxLength;
}

- (NSString *)stringForObjectValue:(id)object {
  return (NSString *)object;
}

- (BOOL)getObjectValue:(id *)object forString:(NSString *)string errorDescription:(NSString **)error {
  *object = string;
  return YES;
}

- (BOOL)isPartialStringValid:(NSString **)partialStringPtr
   proposedSelectedRange:(NSRangePointer)proposedSelRangePtr
          originalString:(NSString *)origString
   originalSelectedRange:(NSRange)origSelRange
        errorDescription:(NSString **)error {
    if ([*partialStringPtr length] > maxLength) {
        return NO;
    }

    if (![*partialStringPtr isEqual:[*partialStringPtr uppercaseString]]) {
      *partialStringPtr = [*partialStringPtr uppercaseString];
      return NO;
    }

    return YES;
}

- (NSAttributedString *)attributedStringForObjectValue:(id)anObject withDefaultAttributes:(NSDictionary *)attributes {
  return nil;
}

@end
Chintan Patel
  • 3,175
  • 3
  • 30
  • 36
Carlos Barbosa
  • 3,083
  • 5
  • 30
  • 30
  • 1
    You should accept Grahams answer, as he pointed you in the correct direction! Good Job tho! – Jab May 07 '09 at 15:12
  • 1
    Thanks for taking the time to come back and post the whole solution! – Matt Gallagher May 18 '09 at 11:45
  • 1
    I discovered an error with the above code. There's a potential exploit using isPartialStringValid:newEditingString:errorDescription:. If you enter text into an NSTextField, character by character on the keyboard, no issues will arise. However, if you paste a string of 2 or more characters into the textfield, it'll perform validation on the very last character entered, but ignore all previously entered characters. This can lead to inserting more characters than allowed into the textfield. Below I'll post more detail and a solution (out of space here). – Dave May 03 '10 at 02:05
  • Can you please edit "- init {" to read "-(id)init{"? Will make it easier to copy/paste. – Chintan Patel Jan 14 '11 at 14:32
  • 1
    remember, if you drag the custom formatter onto the text field from Interface Builder, you will need to use - (id)initWithCoder:(NSCoder *)aDecoder instead of - (id)init to initialize ivars such as maxLength – Ryan Apr 09 '12 at 01:20
  • @ChintanPatel: `- init {` is legitimate, only types other than `id` need to be explicitly put in parentheses as the return type. However, @carlosb, the `init` method is incorrect, the convention is to assign `self` to the result of `[super init]` and only proceed if `self` is not nil. – dreamlax Jan 14 '13 at 21:55
  • It seems that there is a problem with bindings, formatters and continuous updates. This code will only update the string once if continuous update is activated. This link provides a fix: http://lists.apple.com/archives/cocoa-dev/2008/May/msg00083.html – Unfalkster Jul 12 '13 at 18:22
  • This worked fine, but when I hit enter or to get textField.stringvalue all of the inputs chars are removed. – user88975 Feb 26 '14 at 07:55
13

Have you tried attaching a custom NSFormatter subclass?

12

In the above example where I commented, this is bad:

// Don't use:
- (BOOL)isPartialStringValid:(NSString *)partialString
            newEditingString:(NSString **)newString
            errorDescription:(NSString **)error
{
    if ((int)[partialString length] > maxLength)
    {
        *newString = nil;
        return NO;
    }
}

Use this (or something like it) instead:

// Good to use:
- (BOOL)isPartialStringValid:(NSString **)partialStringPtr
       proposedSelectedRange:(NSRangePointer)proposedSelRangePtr
              originalString:(NSString *)origString
       originalSelectedRange:(NSRange)origSelRange
            errorDescription:(NSString **)error
{
    int size = [*partialStringPtr length];
    if ( size > maxLength )
    {
        return NO;
    }
    return YES;
}

Both are NSFormatter methods. The first one has an issue. Say you limit text-entry to 10 characters. If you type characters in one-by-one into an NSTextField, it'll work fine and prevent users from going beyond 10 characters.

However, if a user was to paste a string of, say, 25 characters into the Text Field, what'll happen is something like this:

1) User will paste into TextField

2) TextField will accept the string of characters

3) TextField will apply the formatter to the "last" character in the 25-length string

4) Formatter does stuff to the "last" character in the 25-length string, ignoring the rest

5) TextField will end up with 25 characters in it, even though it's limited to 10.

This is because, I believe, the first method only applies to the "very last character" typed into an NSTextField. The second method shown above applies to "all characters" typed into the NSTextField. So it's immune to the "paste" exploit.

I discovered this just now trying to break my application, and am not an expert on NSFormatter, so please correct me if I'm wrong. And very much thanks to you carlosb for posting that example. It helped a LOT! :)

Dave
  • 12,408
  • 12
  • 64
  • 67
  • 5
    The user does not even need to paste. A user-defined custom key binding (see http://www.hcs.harvard.edu/~jrus/site/cocoa-text.html for details) can insert any string, and a single code point that is outside the Basic Multilingual Plane will be multiple “characters” in Cocoa's two-byte (UTF-16) sense. – Peter Hosey May 05 '10 at 05:41
  • Thanks for that awesome article Peter! – Dave May 06 '10 at 19:18
11

This implementation adopts several of the suggestions commented on above. Notably it works correctly with continuously updating bindings.

In addition:

  1. It implements paste correctly.

  2. It includes some notes on how to use the class effectively in a nib without further subclassing.

The code:

@interface BPPlainTextFormatter : NSFormatter {
    NSInteger _maxLength;
}


/*

 Set the maximum string length. 

 Note that to use this class within a Nib:
 1. Add an NSFormatter as a Custom Formatter.
 2. In the Identity inspector set the Class to BPPlainTextFormatter
 3. In user defined attributes add Key Path: maxLength Type: Number Value: 30

 Note that rather than attaching formatter instances to individual cells they
 can be positioned in the nib Objects section and referenced by numerous controls.
 A name, such as Plain Text Formatter 100, can  be used to identify the formatters max length.

 */
@property NSInteger maxLength;

@end


@implementation BPPlainTextFormatter
@synthesize maxLength = _maxLength;

- (id)init
{
    if(self = [super init]){
        self.maxLength = INT_MAX;
    }

    return self;
}

- (id)initWithCoder:(NSCoder *)aDecoder
{
    // support Nib based initialisation
    self = [super initWithCoder:aDecoder];
    if (self) {
        self.maxLength = INT_MAX;
    }

    return self;
}

#pragma mark -
#pragma mark Textual Representation of Cell Content

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

        // A new NSString is perhaps not required here
        // but generically a new object would be generated
        stringValue = [NSString stringWithString:object];
    }

    return stringValue;
}

#pragma mark -
#pragma mark Object Equivalent to Textual Representation

- (BOOL)getObjectValue:(id *)object forString:(NSString *)string errorDescription:(NSString **)error
{
    BOOL valid = YES;

    // Be sure to generate a new object here or binding woe ensues
    // when continuously updating bindings are enabled.
    *object = [NSString stringWithString:string];

    return valid;
}

#pragma mark -
#pragma mark Dynamic Cell Editing

- (BOOL)isPartialStringValid:(NSString **)partialStringPtr
       proposedSelectedRange:(NSRangePointer)proposedSelRangePtr
              originalString:(NSString *)origString
       originalSelectedRange:(NSRange)origSelRange
            errorDescription:(NSString **)error
{
    BOOL valid = YES;

    NSString *proposedString = *partialStringPtr;
    if ([proposedString length] > self.maxLength) {

        // The original string has been modified by one or more characters (via pasting).
        // Either way compute how much of the proposed string can be accommodated.
        NSInteger origLength = origString.length;
        NSInteger insertLength = self.maxLength - origLength;

        // If a range is selected then characters in that range will be removed
        // so adjust the insert length accordingly
        insertLength += origSelRange.length;

        // Get the string components
        NSString *prefix = [origString substringToIndex:origSelRange.location];
        NSString *suffix = [origString substringFromIndex:origSelRange.location + origSelRange.length];
        NSString *insert = [proposedString substringWithRange:NSMakeRange(origSelRange.location, insertLength)];

#ifdef _TRACE

        NSLog(@"Original string: %@", origString);
        NSLog(@"Original selection location: %u length %u", origSelRange.location, origSelRange.length);

        NSLog(@"Proposed string: %@", proposedString);
        NSLog(@"Proposed selection location: %u length %u", proposedSelRangePtr->location, proposedSelRangePtr->length);

        NSLog(@"Prefix: %@", prefix);
        NSLog(@"Suffix: %@", suffix);
        NSLog(@"Insert: %@", insert);
#endif

        // Assemble the final string
        *partialStringPtr = [[NSString stringWithFormat:@"%@%@%@", prefix, insert, suffix] uppercaseString];

        // Fix-up the proposed selection range
        proposedSelRangePtr->location = origSelRange.location + insertLength;
        proposedSelRangePtr->length = 0;

#ifdef _TRACE

        NSLog(@"Final string: %@", *partialStringPtr);
        NSLog(@"Final selection location: %u length %u", proposedSelRangePtr->location, proposedSelRangePtr->length);

#endif
        valid = NO;
    }

    return valid;
}

@end
Jonathan Mitchell
  • 1,339
  • 12
  • 17
  • Can someone provide solution in swift? I don't know how to convert these lines especially `proposedSelRangePtr->location = origSelRange.location + insertLength;` – prabhu Feb 14 '20 at 10:40
3

I needed a Formatter to convert to uppercase for Swift 4. For reference I've included it here:

import Foundation

class UppercaseFormatter : Formatter {

    override func string(for obj: Any?) -> String? {
        if let stringValue = obj as? String {
            return stringValue.uppercased()
        }
        return nil
    }

    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        obj?.pointee = string as AnyObject
        return true
    }
}
Moshe Gutman
  • 284
  • 4
  • 10
1

Here is a Swift version of Carlos Barbosa's answer, in case anyone needs it.

Example of usage:

myTextField.formatter = CustomTextFieldFormatter(maxLength: 10, isUppercased: true)

class CustomTextFieldFormatter: Formatter {
    var maxLength: UInt
    var isUppercased: Bool
    
    init(maxLength: UInt, isUppercased: Bool) {
        self.maxLength = maxLength
        self.isUppercased = isUppercased
        super.init()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func string(for obj: Any?) -> String? {
        return obj as? String
    }
    
    override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        obj?.pointee = string as AnyObject
        return true
    }
    
    override func isPartialStringValid(_ partialStringPtr: AutoreleasingUnsafeMutablePointer<NSString>, proposedSelectedRange proposedSelRangePtr: NSRangePointer?, originalString origString: String, originalSelectedRange origSelRange: NSRange, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        
        if partialStringPtr.pointee.length > maxLength {
            return false
        }
        
        
        if isUppercased && partialStringPtr.pointee != partialStringPtr.pointee.uppercased as NSString {
            partialStringPtr.pointee = partialStringPtr.pointee.uppercased as NSString
            return false
        }
        
        return true
    }
    
    override func attributedString(for obj: Any, withDefaultAttributes attrs: [NSAttributedString.Key : Any]? = nil) -> NSAttributedString? {
        return nil
    }
}
pkis
  • 6,467
  • 1
  • 17
  • 17
-5

The custom NSFormatter that Graham Lee suggested is the best approach.

A simple kludge would be to set your view controller as the text field's delegate then just block any edit that involves non-uppercase or makes the length longer than 4:

- (BOOL)textField:(UITextField *)textField
    shouldChangeCharactersInRange:(NSRange)range
    replacementString:(NSString *)string
{
    NSMutableString *newValue = [[textField.text mutableCopy] autorelease];
    [newValue replaceCharactersInRange:range withString:string];

    NSCharacterSet *nonUppercase =
        [[NSCharacterSet uppercaseLetterCharacterSet] invertedSet];
    if ([newValue length] > 4 ||
        [newValue rangeOfCharacterFromSet:nonUppercase].location !=
            NSNotFound)
    {
       return NO;
    }

    return YES;
}
Matt Gallagher
  • 14,858
  • 2
  • 41
  • 43