iosobjective-cswiftuitextfieldcredit-card

Formatting a UITextField for credit card input like (xxxx xxxx xxxx xxxx)


I want to format a UITextField for entering a credit card number into such that it only allows digits to be entered and automatically inserts spaces so that the number is formatted like so:

XXXX XXXX XXXX XXXX

How can I do this?


Solution

  • If you're using Swift, go read my port of this answer for Swift 4 and use that instead.

    If you're in Objective-C...

    Firstly, to your UITextFieldDelegate, add these instance variables...

    NSString *previousTextFieldContent;
    UITextRange *previousSelection;
    

    ... and these methods:

    // Version 1.3
    // Source and explanation: http://stackoverflow.com/a/19161529/1709587
    -(void)reformatAsCardNumber:(UITextField *)textField
    {
        // In order to make the cursor end up positioned correctly, we need to
        // explicitly reposition it after we inject spaces into the text.
        // targetCursorPosition keeps track of where the cursor needs to end up as
        // we modify the string, and at the end we set the cursor position to it.
        NSUInteger targetCursorPosition = 
            [textField offsetFromPosition:textField.beginningOfDocument
                               toPosition:textField.selectedTextRange.start];
    
        NSString *cardNumberWithoutSpaces = 
            [self removeNonDigits:textField.text
                      andPreserveCursorPosition:&targetCursorPosition];
    
        if ([cardNumberWithoutSpaces length] > 19) {
            // If the user is trying to enter more than 19 digits, we prevent 
            // their change, leaving the text field in  its previous state.
            // While 16 digits is usual, credit card numbers have a hard 
            // maximum of 19 digits defined by ISO standard 7812-1 in section
            // 3.8 and elsewhere. Applying this hard maximum here rather than
            // a maximum of 16 ensures that users with unusual card numbers
            // will still be able to enter their card number even if the
            // resultant formatting is odd.
            [textField setText:previousTextFieldContent];
            textField.selectedTextRange = previousSelection;
            return;
        }
    
        NSString *cardNumberWithSpaces = 
            [self insertCreditCardSpaces:cardNumberWithoutSpaces
               andPreserveCursorPosition:&targetCursorPosition];
    
        textField.text = cardNumberWithSpaces;
        UITextPosition *targetPosition = 
            [textField positionFromPosition:[textField beginningOfDocument]
                                     offset:targetCursorPosition];
    
        [textField setSelectedTextRange:
            [textField textRangeFromPosition:targetPosition
                                  toPosition:targetPosition]
        ];
    }
    
    -(BOOL)textField:(UITextField *)textField 
             shouldChangeCharactersInRange:(NSRange)range 
                         replacementString:(NSString *)string
    {
        // Note textField's current state before performing the change, in case
        // reformatTextField wants to revert it
        previousTextFieldContent = textField.text;
        previousSelection = textField.selectedTextRange;
    
        return YES;
    }
    
    /*
     Removes non-digits from the string, decrementing `cursorPosition` as
     appropriate so that, for instance, if we pass in `@"1111 1123 1111"`
     and a cursor position of `8`, the cursor position will be changed to
     `7` (keeping it between the '2' and the '3' after the spaces are removed).
     */
    - (NSString *)removeNonDigits:(NSString *)string
                    andPreserveCursorPosition:(NSUInteger *)cursorPosition 
    {
        NSUInteger originalCursorPosition = *cursorPosition;
        NSMutableString *digitsOnlyString = [NSMutableString new];
        for (NSUInteger i=0; i<[string length]; i++) {
            unichar characterToAdd = [string characterAtIndex:i];
            if (isdigit(characterToAdd)) {
                NSString *stringToAdd = 
                    [NSString stringWithCharacters:&characterToAdd
                                            length:1];
    
                [digitsOnlyString appendString:stringToAdd];
            }
            else {
                if (i < originalCursorPosition) {
                    (*cursorPosition)--;
                }
            }
        }
    
        return digitsOnlyString;
    }
    
    /*
     Detects the card number format from the prefix, then inserts spaces into
     the string to format it as a credit card number, incrementing `cursorPosition`
     as appropriate so that, for instance, if we pass in `@"111111231111"` and a
     cursor position of `7`, the cursor position will be changed to `8` (keeping
     it between the '2' and the '3' after the spaces are added).
     */
    - (NSString *)insertCreditCardSpaces:(NSString *)string
                              andPreserveCursorPosition:(NSUInteger *)cursorPosition
    {
        // Mapping of card prefix to pattern is taken from
        // https://baymard.com/checkout-usability/credit-card-patterns
    
        // UATP cards have 4-5-6 (XXXX-XXXXX-XXXXXX) format
        bool is456 = [string hasPrefix: @"1"];
    
        // These prefixes reliably indicate either a 4-6-5 or 4-6-4 card. We treat all
        // these as 4-6-5-4 to err on the side of always letting the user type more
        // digits.
        bool is465 = [string hasPrefix: @"34"] ||
                     [string hasPrefix: @"37"] ||
    
                     // Diners Club
                     [string hasPrefix: @"300"] ||
                     [string hasPrefix: @"301"] ||
                     [string hasPrefix: @"302"] ||
                     [string hasPrefix: @"303"] ||
                     [string hasPrefix: @"304"] ||
                     [string hasPrefix: @"305"] ||
                     [string hasPrefix: @"309"] ||
                     [string hasPrefix: @"36"] ||
                     [string hasPrefix: @"38"] ||
                     [string hasPrefix: @"39"];
    
        // In all other cases, assume 4-4-4-4-3.
        // This won't always be correct; for instance, Maestro has 4-4-5 cards
        // according to https://baymard.com/checkout-usability/credit-card-patterns,
        // but I don't know what prefixes identify particular formats.
        bool is4444 = !(is456 || is465);
    
        NSMutableString *stringWithAddedSpaces = [NSMutableString new];
        NSUInteger cursorPositionInSpacelessString = *cursorPosition;
        for (NSUInteger i=0; i<[string length]; i++) {
            bool needs465Spacing = (is465 && (i == 4 || i == 10 || i == 15));
            bool needs456Spacing = (is456 && (i == 4 || i == 9 || i == 15));
            bool needs4444Spacing = (is4444 && i > 0 && (i % 4) == 0);
    
            if (needs465Spacing || needs456Spacing || needs4444Spacing) {
                [stringWithAddedSpaces appendString:@" "];
                if (i < cursorPositionInSpacelessString) {
                    (*cursorPosition)++;
                }
            }
            unichar characterToAdd = [string characterAtIndex:i];
            NSString *stringToAdd =
            [NSString stringWithCharacters:&characterToAdd length:1];
    
            [stringWithAddedSpaces appendString:stringToAdd];
        }
    
        return stringWithAddedSpaces;
    }
    

    Secondly, set reformatCardNumber: to be called whenever the text field fires a UIControlEventEditingChanged event:

    [yourTextField addTarget:yourTextFieldDelegate 
                                 action:@selector(reformatAsCardNumber:)
                       forControlEvents:UIControlEventEditingChanged];
    

    (Of course, you'll need to do this at some point after your text field and its delegate have been instantiated. If you're using storyboards, the viewDidLoad method of your view controller is an appropriate place.

    Some Explanation

    This is a deceptively complicated problem. Three important issues that may not be immediately obvious (and which previous answers here all fail to take into account):

    1. While the XXXX XXXX XXXX XXXX format for credit and debit card numbers is the most common one, it's not the only one. For example, American Express cards have 15 digit numbers usually written in XXXX XXXXXX XXXXX format, like this:

      An American Express card

      Even Visa cards can have fewer than 16 digits, and Maestro cards can have more:

      A Russian Maestro card with 18 digits

    2. There are more ways for the user to interact with a text field than just typing in single characters at the end of their existing input. You also have to properly handle the user adding characters in the middle of the string, deleting single characters, deleting multiple selected characters, and pasting in multiple characters. Some simpler/more naive approaches to this problem will fail to handle some of these interactions properly. The most perverse case is a user pasting in multiple characters in the middle of the string to replace other characters, and this solution is general enough to handle that.

    3. You don't just need to reformat the text of the text field properly after the user modifies it - you also need to position the text cursor sensibly. Naive approaches to the problem that don't take this into account will almost certainly end up doing something silly with the text cursor in some cases (like putting it to the end of the text field after the user adds a digit in the middle of it).

    To deal with issue #1, we use the partial mapping of card number prefixes to formats curated by The Baymard Institute at https://baymard.com/checkout-usability/credit-card-patterns. We can automatically detect the the card provider from the first couple of digits and (in some cases) infer the format and adjust our formatting accordingly. Thanks to cnotethegr8 for contributing this idea to this answer.

    The simplest and easiest way to deal with issue #2 (and the way used in the code above) is to strip out all spaces and reinsert them in the correct positions every time the content of the text field changes, sparing us the need to figure out what kind of text manipulation (an insertion, a deletion, or a replacement) is going on and handle the possibilities differently.

    To deal with issue #3, we keep track of how the desired index of the cursor changes as we strip out non-digits and then insert spaces. This is why the code rather verbosely performs these manipulations character-by-character using NSMutableString, rather than using NSString's string replacement methods.

    Finally, there's one more trap lurking: returning NO from textField: shouldChangeCharactersInRange: replacementString breaks the 'Cut' button the user gets when they select text in the text field, which is why I don't do it. Returning NO from that method results in 'Cut' simply not updating the clipboard at all, and I know of no fix or workaround. As a result, we need to do the reformatting of the text field in a UIControlEventEditingChanged handler instead of (more obviously) in shouldChangeCharactersInRange: itself.

    Luckily, the UIControl event handlers seem to get called before UI updates get flushed to the screen, so this approach works fine.

    There are also a whole bunch of minor questions about exactly how the text field should behave that don't have obvious correct answers:

    Probably any answer to any of these questions will be adequate, but I list them just to make clear that there are actually a lot of special cases that you might want to think carefully about here, if you were obsessive enough. In the code above, I've picked answers to these questions that seemed reasonable to me. If you happen to have strong feelings about any of these points that aren't compatible with the way my code behaves, it should be easy enough to tweak it to your needs.