Formatting a UITextField for credit card input like (xxxx xxxx xxxx xxxx)
Asked Answered
M

31

80

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?

Mercurial answered 23/8, 2012 at 1:35 Comment(5)
If you can use open source libraries, I'd strongly recommend taking a look at PaymentKit (github.com/stripe/PaymentKit). They have a formatter you can use, and it works for all kinds of cards (and has a validator as well for luhn check and whatnot).Benavides
@MikeWelsh intriguing, and perhaps - for all I know - a better approach than my answer, but I haven't the time or inclination to look into it (especially as I don't own a Mac any more, and haven't done iOS development in over a year). If you have experience with the library, then writing up an answer showing a simple example of how to use it would likely have much more value to future readers than just a comment.Leyes
This answer may help if you are looking for dynamic approach. https://mcmap.net/q/260653/-ios-automatically-add-hyphen-in-text-fieldBenkley
This question continues to attract answers from folks who reckon they're helping by providing a shorter, simpler answer than my (accepted) answer. These answers indeed are shorter and simpler - and as a consequence, not a single one of them works! (And yes, I have personally tested every single one.) This is a deceptively hard problem, people! If you're going to try to provide a better answer, at least read the "Explanation" section of my answer, and the many, many comments I've left explaining ways other people's implementations are broken, and check you're not failing in the same way.Leyes
This answer will help if you want a compact solution and in swift language #37191120Hackery
L
147

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: https://mcmap.net/q/258955/-formatting-a-uitextfield-for-credit-card-input-like-xxxx-xxxx-xxxx-xxxx
-(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:

  • If the user tries to paste in something that would cause the content of the text field to exceed 19 digits, should the beginning of the pasted string be inserted (until 19 digits are reached) and the remainder cropped, or should nothing be inserted at all?
  • If the user tries to delete a single space by positioning their cursor after it and pressing the backspace key, should nothing happen and the cursor remain where it is, should the cursor move left one character (placing it before the space), or should the digit to the left of the space be deleted as though the cursor were already left of the space?
  • When the user types in the fourth, eighth, or twelfth digit, should a space be immediately inserted and the cursor moved after it, or should the space only be inserted after the user types the fifth, ninth, or thirteenth digit?
  • When the user deletes the first digit after a space, if this doesn't cause the space to be removed entirely, should this lead to their cursor being positioned before or after the space?

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.

Leyes answered 3/10, 2013 at 14:11 Comment(6)
I'm getting unrecognized selector sent to instance and a thread problem when I do this. Ideas?Inaccurate
I was able to fix it with this diff: cl.ly/image/45182G0Z3r1O Storing the reference on the controller seems to stop it from being garbage collected, which is what was producing the error in the first place. Hope that helps! cc @MarkAmery in case he wants to review and update his solution.Inaccurate
@JordanFeldsteint glad you solved your issue. What you're describing (having to keep references to objects to avoid them being garbage collected) is a standard issue in Objective-C with ARC and beyond the scope of this answer, I think. Incidentally, the nuisance of having to do such bookkeeping is one of the reasons that many people (including me, and including Apple devs in demos) like to simply use their view controllers as the delegate of everything, rather than creating additional objects to use as delegate. Using pragma marks in Xcode makes this easily manageable even for complex views.Leyes
@MarkAmery I've implemented your ideas in a form of a small library to manage text field formatting github.com/chebur/CHRTextFieldFormatterPolysyndeton
@MarkAmery They way you are calculating your targetCursorPosition at the beginning is broken. Try adding a Character that is not a unicode Scalar, like an emoji. Cursor position will be inaccurate.Steepen
@Steepen Hmm. It's fine here because we're limiting the input to ASCII digits anyway, but that's a trap worth bearing in mind for anyone trying to adapt the technique to fields where any unicode character can be entered.Leyes
L
40

Below is a working Swift 4 port of Logicopolis's answer (which is in turn a Swift 2 port of an old version of my accepted answer in Objective-C) enhanced with cnotethegr8 's trick for supporting Amex cards and then further enhanced to support more card formats. I suggest looking over the accepted answer if you haven't already, since it helps explain the motivation behind a lot of this code.

Note that the minimal series of steps needed to see this in action is:

  1. Create a new Single View App in Swift.
  2. On Main.storyboard, add a Text Field.
  3. Make the ViewController the delegate of the Text Field.
  4. Paste the code below into ViewController.swift.
  5. Connect the IBOutlet to the Text Field.
  6. Run your app and type in the Text Field.

import UIKit

class ViewController: UIViewController, UITextFieldDelegate {
    private var previousTextFieldContent: String?
    private var previousSelection: UITextRange?
    @IBOutlet var yourTextField: UITextField!;

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib
        yourTextField.addTarget(self, action: #selector(reformatAsCardNumber), for: .editingChanged)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        previousTextFieldContent = textField.text;
        previousSelection = textField.selectedTextRange;
        return true
    }

    @objc func reformatAsCardNumber(textField: UITextField) {
        var targetCursorPosition = 0
        if let startPosition = textField.selectedTextRange?.start {
            targetCursorPosition = textField.offset(from: textField.beginningOfDocument, to: startPosition)
        }

        var cardNumberWithoutSpaces = ""
        if let text = textField.text {
            cardNumberWithoutSpaces = self.removeNonDigits(string: text, andPreserveCursorPosition: &targetCursorPosition)
        }

        if cardNumberWithoutSpaces.count > 19 {
            textField.text = previousTextFieldContent
            textField.selectedTextRange = previousSelection
            return
        }

        let cardNumberWithSpaces = self.insertCreditCardSpaces(cardNumberWithoutSpaces, preserveCursorPosition: &targetCursorPosition)
        textField.text = cardNumberWithSpaces

        if let targetPosition = textField.position(from: textField.beginningOfDocument, offset: targetCursorPosition) {
            textField.selectedTextRange = textField.textRange(from: targetPosition, to: targetPosition)
        }
    }

    func removeNonDigits(string: String, andPreserveCursorPosition cursorPosition: inout Int) -> String {
        var digitsOnlyString = ""
        let originalCursorPosition = cursorPosition

        for i in Swift.stride(from: 0, to: string.count, by: 1) {
            let characterToAdd = string[string.index(string.startIndex, offsetBy: i)]
            if characterToAdd >= "0" && characterToAdd <= "9" {
                digitsOnlyString.append(characterToAdd)
            }
            else if i < originalCursorPosition {
                cursorPosition -= 1
            }
        }

        return digitsOnlyString
    }

    func insertCreditCardSpaces(_ string: String, preserveCursorPosition cursorPosition: inout Int) -> String {
        // 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
        let 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.
        let is465 = [
            // Amex
            "34", "37",

            // Diners Club
            "300", "301", "302", "303", "304", "305", "309", "36", "38", "39"
        ].contains { string.hasPrefix($0) }

        // 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.
        let is4444 = !(is456 || is465)

        var stringWithAddedSpaces = ""
        let cursorPositionInSpacelessString = cursorPosition

        for i in 0..<string.count {
            let needs465Spacing = (is465 && (i == 4 || i == 10 || i == 15))
            let needs456Spacing = (is456 && (i == 4 || i == 9 || i == 15))
            let needs4444Spacing = (is4444 && i > 0 && (i % 4) == 0)

            if needs465Spacing || needs456Spacing || needs4444Spacing {
                stringWithAddedSpaces.append(" ")

                if i < cursorPositionInSpacelessString {
                    cursorPosition += 1
                }
            }

            let characterToAdd = string[string.index(string.startIndex, offsetBy:i)]
            stringWithAddedSpaces.append(characterToAdd)
        }

        return stringWithAddedSpaces
    }
}

Adapting this to other situations - like your delegate not being a ViewController - is left as an exercise for the reader.

Leyes answered 23/8, 2012 at 1:35 Comment(7)
textField.selectedTextRange = textField.textRange(from: targetPosition, to: targetPosition) only works correctly if I dispatch it on the main thread with DispatchQueue.main.async.. I suppose the textview is in a state at this point where textField.selectedTextRange gets ignored. The re-dispatching works around the problem.Teri
I think this can crash. Enter 4111111111111111111, (should look like 4111 1111 1111 1111 111), backspace over the last three digits, shake to undo and choose undo. *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[NSBigMutableString substringWithRange:]: Range {20, 0} out of bounds; string length 19'Triangulation
@JoshParadroid Oooh, yikes. :( I can immediately believe you - I know that I did not consider "undo" when first implementing this in Objective C back in 2013. Heck, I'm not sure if there even was "undo" functionality back in iOS 6. I will have a look when I get the chance and see if there's anything I can do to fix it, but I don't know if there will even be a way to walk the line between doing whatever is needed to properly support "undo" and also meeting the other constraints I outline in the accepted answer. Perhaps it's possible to suppress the button so the user can't "undo" at all?Leyes
@MarkAmery for the situation where I'm currently using this, suppressing it would be good enough, but I'm not currently having much luck doing that. Thanks for the fast reply on an old question.Triangulation
@MarkAmery textField.undoManager?.removeAllActions() at the end of reformatAsCardNumber will stop the crash occurring. It's not a great fix but it works.Triangulation
@MarkAmery space is being added when I enter a specific number, can space being added just after the character entered. Like suppose after 4 characters there should be a space, so in current scenario when I'll enter 5th character, there would be a space; but I want a space just after 4 characters; so when I'll enter 5th character it should start after space initially.Iloilo
@MarkAmery Let's say I want to format card numbers like 4012 0*** ***3 0026 while typing, how should we approach this existing solution?Discriminating
G
26

You can probably optimize my code or there might be an easier way but this code should work:

-(BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {

    __block NSString *text = [textField text];

    NSCharacterSet *characterSet = [NSCharacterSet characterSetWithCharactersInString:@"0123456789\b"];
    string = [string stringByReplacingOccurrencesOfString:@" " withString:@""];
    if ([string rangeOfCharacterFromSet:[characterSet invertedSet]].location != NSNotFound) {
        return NO;
    }

    text = [text stringByReplacingCharactersInRange:range withString:string];
    text = [text stringByReplacingOccurrencesOfString:@" " withString:@""];

    NSString *newString = @"";
    while (text.length > 0) {
        NSString *subString = [text substringToIndex:MIN(text.length, 4)];
        newString = [newString stringByAppendingString:subString];
        if (subString.length == 4) {
            newString = [newString stringByAppendingString:@" "];
        }
        text = [text substringFromIndex:MIN(text.length, 4)];
    }

    newString = [newString stringByTrimmingCharactersInSet:[characterSet invertedSet]];

    if (newString.length >= 20) {
        return NO;
    }

    [textField setText:newString];

    return NO;
}
Greater answered 23/8, 2012 at 4:0 Comment(2)
There is at least one significant flaw with this solution: if I type, say, '1234', and then move the text cursor to just after the '1' and then type or delete a character, suddenly my text cursor jumps to the end of the text field again.Leyes
Would this detroy the text being stored ? or does it only effect the text being displayed ?Kato
S
13

Swift 3 solution using Fawkes answer as basic. Added Amex Card format support. Added reformation when card type changed.

First make new class with this code:

extension String {

    func containsOnlyDigits() -> Bool
    {

        let notDigits = NSCharacterSet.decimalDigits.inverted

        if rangeOfCharacter(from: notDigits, options: String.CompareOptions.literal, range: nil) == nil
        {
            return true
        }

        return false
    }
}
import UIKit

var creditCardFormatter : CreditCardFormatter
{
    return CreditCardFormatter.sharedInstance
}

class CreditCardFormatter : NSObject
{
    static let sharedInstance : CreditCardFormatter = CreditCardFormatter()

    func formatToCreditCardNumber(isAmex: Bool, textField : UITextField, withPreviousTextContent previousTextContent : String?, andPreviousCursorPosition previousCursorSelection : UITextRange?) {
        var selectedRangeStart = textField.endOfDocument
        if textField.selectedTextRange?.start != nil {
            selectedRangeStart = (textField.selectedTextRange?.start)!
        }
        if  let textFieldText = textField.text
        {
            var targetCursorPosition : UInt = UInt(textField.offset(from:textField.beginningOfDocument, to: selectedRangeStart))
            let cardNumberWithoutSpaces : String = removeNonDigitsFromString(string: textFieldText, andPreserveCursorPosition: &targetCursorPosition)
            if cardNumberWithoutSpaces.characters.count > 19
            {
                textField.text = previousTextContent
                textField.selectedTextRange = previousCursorSelection
                return
            }
            var cardNumberWithSpaces = ""
            if isAmex {
                cardNumberWithSpaces = insertSpacesInAmexFormat(string: cardNumberWithoutSpaces, andPreserveCursorPosition: &targetCursorPosition)
            }
            else
            {
                cardNumberWithSpaces = insertSpacesIntoEvery4DigitsIntoString(string: cardNumberWithoutSpaces, andPreserveCursorPosition: &targetCursorPosition)
            }
            textField.text = cardNumberWithSpaces
            if let finalCursorPosition = textField.position(from:textField.beginningOfDocument, offset: Int(targetCursorPosition))
            {
                textField.selectedTextRange = textField.textRange(from: finalCursorPosition, to: finalCursorPosition)
            }
        }
    }

    func removeNonDigitsFromString(string : String, andPreserveCursorPosition cursorPosition : inout UInt) -> String {
        var digitsOnlyString : String = ""
        for index in stride(from: 0, to: string.characters.count, by: 1)
        {
            let charToAdd : Character = Array(string.characters)[index]
            if isDigit(character: charToAdd)
            {
                digitsOnlyString.append(charToAdd)
            }
            else
            {
                if index < Int(cursorPosition)
                {
                    cursorPosition -= 1
                }
            }
        }
        return digitsOnlyString
    }

    private func isDigit(character : Character) -> Bool
    {
        return "\(character)".containsOnlyDigits()
    }

    func insertSpacesInAmexFormat(string : String, andPreserveCursorPosition cursorPosition : inout UInt) -> String {
        var stringWithAddedSpaces : String = ""
        for index in stride(from: 0, to: string.characters.count, by: 1)
        {
            if index == 4
            {
                stringWithAddedSpaces += " "
                if index < Int(cursorPosition)
                {
                    cursorPosition += 1
                }
            }
            if index == 10 {
                stringWithAddedSpaces += " "
                if index < Int(cursorPosition)
                {
                    cursorPosition += 1
                }
            }
            if index < 15 {
               let characterToAdd : Character = Array(string.characters)[index]
                stringWithAddedSpaces.append(characterToAdd)
            }
        }
        return stringWithAddedSpaces
    }


    func insertSpacesIntoEvery4DigitsIntoString(string : String, andPreserveCursorPosition cursorPosition : inout UInt) -> String {
        var stringWithAddedSpaces : String = ""
        for index in stride(from: 0, to: string.characters.count, by: 1)
        {
            if index != 0 && index % 4 == 0 && index < 16
            {
                stringWithAddedSpaces += " "

                if index < Int(cursorPosition)
                {
                    cursorPosition += 1
                }
            }
            if index < 16 {
                let characterToAdd : Character = Array(string.characters)[index]
                stringWithAddedSpaces.append(characterToAdd)
            }
        }
        return stringWithAddedSpaces
    }

}

In your ViewControllerClass add this function

func reformatAsCardNumber(textField:UITextField){
  let formatter = CreditCardFormatter()
  var isAmex = false
  if selectedCardType == "AMEX" {
    isAmex = true
    }
  formatter.formatToCreditCardNumber(isAmex: isAmex, textField: textField, withPreviousTextContent: textField.text, andPreviousCursorPosition: textField.selectedTextRange)
}

Then add target to your textField

youtTextField.addTarget(self, action: #selector(self.reformatAsCardNumber(textField:)), for: UIControlEvents.editingChanged)

Register new variable and sent card type to it

var selectedCardType: String? {
  didSet{
    reformatAsCardNumber(textField: yourTextField)
  }
}

Thanks Fawkes for his code!

Slimy answered 4/11, 2016 at 12:57 Comment(2)
withPreviousTextContent doesn't have correct value.Besprent
-1; this doesn't properly handle deleting the digit after a space. If I have 1234 5678 9012 in my text field, and I position my text cursor after the 9 and hit backspace, the 9 is deleted but my text cursor ends up after the 0 rather than after the 8.Leyes
C
12

I think this one is good:

-(BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
    {

        NSLog(@"%@",NSStringFromRange(range));

        // Only the 16 digits + 3 spaces
        if (range.location == 19) {
            return NO;
        }

        // Backspace
        if ([string length] == 0)
            return YES;

        if ((range.location == 4) || (range.location == 9) || (range.location == 14))
        {

            NSString *str    = [NSString stringWithFormat:@"%@ ",textField.text];
            textField.text   = str;
        }

        return YES;
    }
Cutworm answered 25/1, 2013 at 22:17 Comment(3)
This answer is broken too. The positioning of the spaces gets broken if I go back and delete characters after typing the number in.Leyes
does it break with this? if ([string length] == 0) return YES;Cutworm
yes - there's still loads more broken here. For one thing, as long as I keep moving my text cursor to the left of the box, I can type as long a number as I like!Leyes
W
10

So I wanted to this with less code, so I used the code here and repurposed it a little bit. I had two fields in the screen, one for the number and one for the expiry date, so I made it more reusable.

Swift 3 alternate answer

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    guard let currentText = (textField.text as NSString?)?.replacingCharacters(in: range, with: string) else { return true }

    if textField == cardNumberTextField {
        textField.text = currentText.grouping(every: 4, with: " ")
        return false
    }
    else { // Expiry Date Text Field
        textField.text = currentText.grouping(every: 2, with: "/")
        return false
    }
}

extension String {
    func grouping(every groupSize: String.IndexDistance, with separator: Character) -> String {
       let cleanedUpCopy = replacingOccurrences(of: String(separator), with: "")
       return String(cleanedUpCopy.characters.enumerated().map() {
            $0.offset % groupSize == 0 ? [separator, $0.element] : [$0.element]
       }.joined().dropFirst())
    }
}
Waitress answered 25/7, 2017 at 8:24 Comment(1)
-1. Everyone wants to do this with less code, but so far nobody has succeeded. This answer (like most of the others) is broken in a way that I already warned about in my answer: if you move the text cursor to somewhere other than the end of the text field and type a digit, the text cursor jumps to the end of the text field, which shouldn't happen.Leyes
J
8
func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool
    {
        if textField == CardNumTxt
        {
            let replacementStringIsLegal = string.rangeOfCharacterFromSet(NSCharacterSet(charactersInString: "0123456789").invertedSet) == nil

            if !replacementStringIsLegal
            {
                return false
            }

            let newString = (textField.text! as NSString).stringByReplacingCharactersInRange(range, withString: string)
            let components = newString.componentsSeparatedByCharactersInSet(NSCharacterSet(charactersInString: "0123456789").invertedSet)

            let decimalString = components.joinWithSeparator("") as NSString
            let length = decimalString.length
            let hasLeadingOne = length > 0 && decimalString.characterAtIndex(0) == (1 as unichar)

            if length == 0 || (length > 16 && !hasLeadingOne) || length > 19
            {
                let newLength = (textField.text! as NSString).length + (string as NSString).length - range.length as Int

                return (newLength > 16) ? false : true
            }
            var index = 0 as Int
            let formattedString = NSMutableString()

            if hasLeadingOne
            {
                formattedString.appendString("1 ")
                index += 1
            }
            if length - index > 4
            {
                let prefix = decimalString.substringWithRange(NSMakeRange(index, 4))
                formattedString.appendFormat("%@-", prefix)
                index += 4
            }

            if length - index > 4
            {
                let prefix = decimalString.substringWithRange(NSMakeRange(index, 4))
                formattedString.appendFormat("%@-", prefix)
                index += 4
            }
            if length - index > 4
            {
                let prefix = decimalString.substringWithRange(NSMakeRange(index, 4))
                formattedString.appendFormat("%@-", prefix)
                index += 4
            }


            let remainder = decimalString.substringFromIndex(index)
            formattedString.appendString(remainder)
            textField.text = formattedString as String
            return false
        }
        else
        {
            return true
        }
    }

formattedString.appendFormat("%@-", prefix) chage of "-" any other your choose

Jemimah answered 17/3, 2016 at 6:51 Comment(2)
-1; like many other answers here, this behaves horribly if you move the text cursor. Every digit I type, the cursor leaps to the right-hand side of the text field, regardless of where I typed the digit.Leyes
It works for me but converted it into latest swift code.Anjaanjali
D
8

In Swift 5 :

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        if textField == cardNumberTextField {
            return formatCardNumber(textField: textField, shouldChangeCharactersInRange: range, replacementString: string)
        }
        return true
    }


    func formatCardNumber(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool {
        if textField == cardNumberTextField {
            let replacementStringIsLegal = string.rangeOfCharacter(from: NSCharacterSet(charactersIn: "0123456789").inverted) == nil

            if !replacementStringIsLegal {
                return false
            }

            let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string)
            let components = newString.components(separatedBy: NSCharacterSet(charactersIn: "0123456789").inverted)
            let decimalString = components.joined(separator: "") as NSString
            let length = decimalString.length
            let hasLeadingOne = length > 0 && decimalString.character(at: 0) == (1 as unichar)

            if length == 0 || (length > 16 && !hasLeadingOne) || length > 19 {
                let newLength = (textField.text! as NSString).length + (string as NSString).length - range.length as Int

                return (newLength > 16) ? false : true
            }
            var index = 0 as Int
            let formattedString = NSMutableString()

            if hasLeadingOne {
                formattedString.append("1 ")
                index += 1
            }
            if length - index > 4 {
                let prefix = decimalString.substring(with: NSRange(location: index, length: 4))
                formattedString.appendFormat("%@ ", prefix)
                index += 4
            }

            if length - index > 4 {
                let prefix = decimalString.substring(with: NSRange(location: index, length: 4))
                formattedString.appendFormat("%@ ", prefix)
                index += 4
            }
            if length - index > 4 {
                let prefix = decimalString.substring(with: NSRange(location: index, length: 4))
                formattedString.appendFormat("%@ ", prefix)
                index += 4
            }

            let remainder = decimalString.substring(from: index)
            formattedString.append(remainder)
            textField.text = formattedString as String
            return false
        } else {
            return true
        }
    }
Dendrology answered 9/3, 2020 at 17:20 Comment(0)
H
7

Here is the Swift 5 version of Mark Amery's accepted answer.

Add these variables in your class:

@IBOutlet weak var cardNumberTextField: UITextField!
private var previousTextFieldContent: String?
private var previousSelection: UITextRange?

Also ensure that your text field calls reformatAsCardNumber: from viewDidLoad():

cardNumberTextField.addTarget(self, action: #selector(reformatAsCardNumber), for: .editingChanged)

Add this in your UITextFieldDelegate:

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
   
    if textField == cardNumberTextField {
        previousTextFieldContent = textField.text;
        previousSelection = textField.selectedTextRange;
    }
    
    return true
}

Lastly include the following methods in your viewController:

@objc func reformatAsCardNumber(textField: UITextField) {
    var targetCursorPosition = 0
    if let startPosition = textField.selectedTextRange?.start {
        targetCursorPosition = textField.offset(from: textField.beginningOfDocument, to: startPosition)
    }
    
    var cardNumberWithoutSpaces = ""
    if let text = textField.text {
        cardNumberWithoutSpaces = self.removeNonDigits(string: text, andPreserveCursorPosition: &targetCursorPosition)
    }
    
    if cardNumberWithoutSpaces.count > 19 {
        textField.text = previousTextFieldContent
        textField.selectedTextRange = previousSelection
        return
    }
    
    let cardNumberWithSpaces = self.insertSpacesEveryFourDigitsIntoString(string: cardNumberWithoutSpaces, andPreserveCursorPosition: &targetCursorPosition)
    textField.text = cardNumberWithSpaces
    
    if let targetPosition = textField.position(from: textField.beginningOfDocument, offset: targetCursorPosition) {
        textField.selectedTextRange = textField.textRange(from: targetPosition, to: targetPosition)
    }
}

func removeNonDigits(string: String, andPreserveCursorPosition cursorPosition: inout Int) -> String {
    var digitsOnlyString = ""
    let originalCursorPosition = cursorPosition
    
    for i in Swift.stride(from: 0, to: string.count, by: 1) {
        let characterToAdd = string[string.index(string.startIndex, offsetBy: i)]
        if characterToAdd >= "0" && characterToAdd <= "9" {
            digitsOnlyString.append(characterToAdd)
        }
        else if i < originalCursorPosition {
            cursorPosition -= 1
        }
    }
    
    return digitsOnlyString
}

func insertSpacesEveryFourDigitsIntoString(string: String, andPreserveCursorPosition cursorPosition: inout Int) -> String {
    var stringWithAddedSpaces = ""
    let cursorPositionInSpacelessString = cursorPosition
    
    for i in Swift.stride(from: 0, to: string.count, by: 1) {
        if i > 0 && (i % 4) == 0 {
            stringWithAddedSpaces.append(contentsOf: " ")
            if i < cursorPositionInSpacelessString {
                cursorPosition += 1
            }
        }
        let characterToAdd = string[string.index(string.startIndex, offsetBy: i)]
        stringWithAddedSpaces.append(characterToAdd)
    }
    
    return stringWithAddedSpaces
}
Hax answered 19/8, 2020 at 20:11 Comment(0)
W
5

Yet another version of the accepted answer in Swift 2...

Ensure you have these in your delegate instance:

private var previousTextFieldContent: String?
private var previousSelection: UITextRange?

And also ensure that your text field calls reformatAsCardNumber:

textField.addTarget(self, action: #selector(reformatAsCardNumber(_:)), forControlEvents: .EditingChanged)

You text field delegate will need to do this:

func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool {
    previousTextFieldContent = textField.text;
    previousSelection = textField.selectedTextRange;
    return true
}

Lastly include the following methods:

func reformatAsCardNumber(textField: UITextField) {
    var targetCursorPosition = 0
    if let startPosition = textField.selectedTextRange?.start {
        targetCursorPosition = textField.offsetFromPosition(textField.beginningOfDocument, toPosition: startPosition)
    }

    var cardNumberWithoutSpaces = ""
    if let text = textField.text {
        cardNumberWithoutSpaces = self.removeNonDigits(text, andPreserveCursorPosition: &targetCursorPosition)
    }

    if cardNumberWithoutSpaces.characters.count > 19 {
        textField.text = previousTextFieldContent
        textField.selectedTextRange = previousSelection
        return
    }

    let cardNumberWithSpaces = self.insertSpacesEveryFourDigitsIntoString(cardNumberWithoutSpaces, andPreserveCursorPosition: &targetCursorPosition)
    textField.text = cardNumberWithSpaces

    if let targetPosition = textField.positionFromPosition(textField.beginningOfDocument, offset: targetCursorPosition) {
        textField.selectedTextRange = textField.textRangeFromPosition(targetPosition, toPosition: targetPosition)
    }
}

func removeNonDigits(string: String, inout andPreserveCursorPosition cursorPosition: Int) -> String {
    var digitsOnlyString = ""
    let originalCursorPosition = cursorPosition

    for i in 0.stride(to: string.characters.count, by: 1) {
        let characterToAdd = string[string.startIndex.advancedBy(i)]
        if characterToAdd >= "0" && characterToAdd <= "9" {
            digitsOnlyString.append(characterToAdd)
        }
        else if i < originalCursorPosition {
            cursorPosition -= 1
        }
    }

    return digitsOnlyString
}

func insertSpacesEveryFourDigitsIntoString(string: String, inout andPreserveCursorPosition cursorPosition: Int) -> String {
    var stringWithAddedSpaces = ""
    let cursorPositionInSpacelessString = cursorPosition

    for i in 0.stride(to: string.characters.count, by: 1) {
        if i > 0 && (i % 4) == 0 {
            stringWithAddedSpaces.appendContentsOf(" ")
            if i < cursorPositionInSpacelessString {
                cursorPosition += 1
            }
        }
        let characterToAdd = string[string.startIndex.advancedBy(i)]
        stringWithAddedSpaces.append(characterToAdd)
    }

    return stringWithAddedSpaces
}
Wolcott answered 9/6, 2016 at 17:34 Comment(1)
Great work - this is the only Swift conversion of my answer that actually works; indeed, besides mine, it's the only answer here that works at all out of the staggering 27 (mostly garbage) answers that this question has attracted. I've edited this to note that it works for Swift 2, and have also used it as the basis for my own Swift 4 port. Just wanted to say thanks and let you know!Leyes
L
4

Here is a Swift version in case this is useful to anyone still looking for this answer but using Swift instead of Objective-C. The concepts are still the same regardless.

func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool
{
    //range.length will be greater than 0 if user is deleting text - allow it to replace
    if range.length > 0
    {
        return true
    }

    //Don't allow empty strings
    if string == " "
    {
        return false
    }

    //Check for max length including the spacers we added
    if range.location == 20
    {
        return false
    }

    var originalText = textField.text
    let replacementText = string.stringByReplacingOccurrencesOfString(" ", withString: "")

    //Verify entered text is a numeric value
    let digits = NSCharacterSet.decimalDigitCharacterSet()
    for char in replacementText.unicodeScalars
    {
        if !digits.longCharacterIsMember(char.value)
        {
            return false
        }
    }

    //Put an empty space after every 4 places
    if originalText!.length() % 5 == 0
    {
        originalText?.appendContentsOf(" ")
        textField.text = originalText
    }

    return true
}
Leaguer answered 1/10, 2015 at 18:20 Comment(2)
nice code. Works for me but got one problem. It will put blank space at the start of the string. For e.g. if i want to write 4242424242424242 then out put of this string will be " 4242 4242 4242 4242".Mesopotamia
-1; besides the blank space at the start mentioned by @Birju, this is also broken if I move the text cursor to an earlier position in the string; if I type there, it will not only break the spacing between the blocks of 4 numbers, but will also let me overflow the character limit.Leyes
A
4

In order to achieve the goal of format the text entered in the textfield in this way XXXX XXXX XXXX XXXX is important to keep in mind some important things. Beside the fact that the 16 digits card number separated every four digit is the most common used format, there are cards with 15 digits (AmEx formatted XXXX XXXXXX XXXXX) and others with 13 digits or even with 19 digits (https://en.wikipedia.org/wiki/Payment_card_number ). Other important thing you should consider is configure the textField to allow only digits, configure the keyboard type as numberPad is a good start, but is convenient to implement a method which secure the input.

A starting point is decide when you want to format the number, while the user is entering the number or when the user leave the text field. In the case that you want to format when the user leave the textField is convenient to use the textFieldDidEndEditing(_:) delegate's method take the content of the textField and format it.

In the case you while the user is entering the number is useful the textField(_:shouldChangeCharactersIn:replacementString:) delegate method which is called whenever the current text changes.

In both cases there is still a problem, figure out which is the correct format for the entered number, IMHO and based on all the numbers that I have seen, there are only two main formats: the Amex format with 15 digits described above and the format which group card number every four digits which don not care of how much digits there are, being this case like a generic rule, for example a card with 13 digits will be formatted XXXXX XXXX XXXX X and with 19 digits will look like this XXXX XXXX XXXX XXXX XXX, this will work for the most common cases (16 digits) and for the others as well. So you could figure out how to manage the AmEx case with the same algorithm below playing with the magic numbers.

I used a RegEx to ensure that a 15 digits card is an American express, in the case of other particular formats

let regex = NSPredicate(format: "SELF MATCHES %@", "3[47][A-Za-z0-9*-]{13,}" )
let isAmex = regex.evaluate(with: stringToValidate)

I strongly recommend to use the specific RegEx which is useful to identify the Issuer and to figure out how many digits should be accepted.

Now my swift approach of the solution with textFieldDidEndEditing is

func textFieldDidEndEditing(_ textField: UITextField) {

    _=format(cardNumber: textField.text!)

}
func format(cardNumber:String)->String{
    var formatedCardNumber = ""
    var i :Int = 0
    //loop for every character
    for character in cardNumber.characters{
        //in case you want to replace some digits in the middle with * for security
        if(i < 6 || i >= cardNumber.characters.count - 4){
            formatedCardNumber = formatedCardNumber + String(character)
        }else{
            formatedCardNumber = formatedCardNumber + "*"
        }
        //insert separators every 4 spaces(magic number)
        if(i == 3 || i == 7 || i == 11 || (i == 15 && cardNumber.characters.count > 16 )){
            formatedCardNumber = formatedCardNumber + "-"
            // could use just " " for spaces
        }

        i = i + 1
    }
    return formatedCardNumber
}

and for shouldChangeCharactersIn:replacementString: a Swift 3.0 From Jayesh Miruliya Answer, put a separator between the group of four characters

 func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool
    {
        if textField == CardNumTxt
        {
            let replacementStringIsLegal = string.rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789").inverted) == nil

        if !replacementStringIsLegal
        {
            return false
        }

        let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string)
        let components = newString.components(separatedBy: CharacterSet(charactersIn: "0123456789").inverted)

        let decimalString = components.joined(separator: "") as NSString
        let length = decimalString.length
        let hasLeadingOne = length > 0 && decimalString.character(at: 0) == (1 as unichar)

        if length == 0 || (length > 16 && !hasLeadingOne) || length > 19
        {
            let newLength = (textField.text! as NSString).length + (string as NSString).length - range.length as Int

            return (newLength > 16) ? false : true
        }
        var index = 0 as Int
        let formattedString = NSMutableString()

        if hasLeadingOne
        {
            formattedString.append("1 ")
            index += 1
        }
        if length - index > 4 //magic number separata every four characters
        {
            let prefix = decimalString.substring(with: NSMakeRange(index, 4))
            formattedString.appendFormat("%@-", prefix)
            index += 4
        }

        if length - index > 4
        {
            let prefix = decimalString.substring(with: NSMakeRange(index, 4))
            formattedString.appendFormat("%@-", prefix)
            index += 4
        }
        if length - index > 4
        {
            let prefix = decimalString.substring(with: NSMakeRange(index, 4))
            formattedString.appendFormat("%@-", prefix)
            index += 4
        }


        let remainder = decimalString.substring(from: index)
        formattedString.append(remainder)
        textField.text = formattedString as String
        return false
        }
        else
        {
            return true
        }
    }
Astor answered 18/4, 2017 at 22:14 Comment(2)
this code looks so scary. you can use a "while" instead of 3 if statements, and avoid a lot of unneeded variablesStrong
-1; as you note, most of the code here is just copied and pasted from another user's answer (and, as I note on that answer, doesn't work), and the remainder doesn't answer the question.Leyes
F
4

Swift 3.2

Little correction in the @Lucas answer and working code in swift 3.2. Also removing the space character automatically.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {

    if range.location == 19 {
        return false
    }

    if range.length == 1 {
        if (range.location == 5 || range.location == 10 || range.location == 15) {
            let text = textField.text ?? ""
            textField.text = text.substring(to: text.index(before: text.endIndex))
        }
        return true
    }

    if (range.location == 4 || range.location == 9 || range.location == 14) {
        textField.text = String(format: "%@ ", textField.text ?? "")
    }

    return true
}
Fusain answered 18/10, 2017 at 13:43 Comment(1)
-1; this is totally broken. If I move the cursor to anywhere other than the end of the text field and type, I can break the formatting and violate the length limits. Also, really weird stuff happens if I move the cursor to somewhere that's not at the end of the text field and delete characters; whole chunks of characters got chomped off the end.Leyes
E
3

Define below method & call it in UITextfield delegates or wherever required

-(NSString*)processString :(NSString*)yourString
{
    if(yourString == nil){
        return @"";
    }
    int stringLength = (int)[yourString length];
    int len = 4;  // Length after which you need to place added character
    NSMutableString *str = [NSMutableString string];
    int i = 0;
    for (; i < stringLength; i+=len) {
        NSRange range = NSMakeRange(i, len);
        [str appendString:[yourString substringWithRange:range]];
        if(i!=stringLength -4){
            [str appendString:@" "]; //If required string format is XXXX-XXXX-XXXX-XXX then just replace [str appendString:@"-"]
        }
    }
    if (i < [str length]-1) {  // add remaining part
        [str appendString:[yourString substringFromIndex:i]];
    }
    //Returning required string

    return str;
}
Errancy answered 26/11, 2014 at 6:8 Comment(1)
It's unclear to me how you intend this method to be used, and it doesn't do anything to handle text cursor positioning. -1.Leyes
P
3

Xcode 14, Swift 5.7:

Below solution should work by considering cursor situations as well. In most of the answers when we edit cursor is moved to end of field.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    let textFieldText = textField.text as NSString?
    let value = textFieldText?.replacingCharacters(in: range, with: string) ?? ""
    textField.setText(to: value.grouping(every: 4, with: " "), preservingCursor: true)
    return false
}

extension UITextField {
    public func setText(to newText: String, preservingCursor: Bool) {
        if preservingCursor {
            let cursorPosition = offset(from: beginningOfDocument, to: selectedTextRange!.start) + newText.count - (text?.count ?? 0)
            text = newText
            if let newPosition = self.position(from: beginningOfDocument, offset: cursorPosition) {
                selectedTextRange = textRange(from: newPosition, to: newPosition)
            }}
        else{
            text = newText
        }
    }
}

extension String {
    
    func grouping(every groupSize: String.IndexDistance, with separator: Character) -> String {
        let cleanedUpCopy = replacingOccurrences(of: String(separator), with: "")
        return String(cleanedUpCopy.enumerated().map() {
            $0.offset % groupSize == 0 ? [separator, $0.element] : [$0.element]
        }.joined().dropFirst())
    }
}
Petitioner answered 15/10, 2022 at 17:21 Comment(0)
C
2

Swift 3 solution based upon Mark Amery's Objective-C solution:

  1. Implement action and delegate methods:

    textField.addTarget(self, action: #selector(reformatAsCardNumber(_:))
    textField.delegate = self
    
  2. TextField Delegate methods and other methods:

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        previousTextFieldContent = textField.text;
        previousSelection = textField.selectedTextRange;
        return true
    }
    
    func reformatAsCardNumber(_ textField: UITextField) {
        var targetCursorPosition = 0
        if let startPosition = textField.selectedTextRange?.start {
            targetCursorPosition = textField.offset(from:textField.beginningOfDocument, to: startPosition)
        }
    
        var cardNumberWithoutSpaces = ""
        if let text = textField.text {
            cardNumberWithoutSpaces = removeNonDigits(string: text, andPreserveCursorPosition: &targetCursorPosition)
        }
    
        if cardNumberWithoutSpaces.characters.count > 19 {
            textField.text = previousTextFieldContent
            textField.selectedTextRange = previousSelection
            return
        }
    
        let cardNumberWithSpaces = self.insertSpacesEveryFourDigitsIntoString(string: cardNumberWithoutSpaces, andPreserveCursorPosition: &targetCursorPosition)
        textField.text = cardNumberWithSpaces
    
        if let targetPosition = textField.position(from: textField.beginningOfDocument, offset: targetCursorPosition) {
            textField.selectedTextRange = textField.textRange(from: targetPosition, to: targetPosition)
        }
    }
    
    func removeNonDigits(string: String, andPreserveCursorPosition cursorPosition: inout Int) -> String {
        var digitsOnlyString = ""
        let originalCursorPosition = cursorPosition
    
        for i in stride(from: 0, to: string.characters.count, by: 1) {
            let characterToAdd =  string[string.index(string.startIndex, offsetBy: i)]
            if characterToAdd >= "0" && characterToAdd <= "9" {
                digitsOnlyString.append(characterToAdd)
            }
            else if i < originalCursorPosition {
                cursorPosition -= 1
            }
        }
    
        return digitsOnlyString
    }
    
    func insertSpacesEveryFourDigitsIntoString(string: String, andPreserveCursorPosition cursorPosition: inout Int) -> String {
        var stringWithAddedSpaces = ""
        let cursorPositionInSpacelessString = cursorPosition
    
        for i in stride(from: 0, to: string.characters.count, by: 1) {
            if i > 0 && (i % 4) == 0 {
                stringWithAddedSpaces.append(" ")
                if i < cursorPositionInSpacelessString {
                    cursorPosition += 1
                }
            }
            let characterToAdd = string[string.index(string.startIndex, offsetBy: i)]
            stringWithAddedSpaces.append(characterToAdd)
        }
    
        return stringWithAddedSpaces
    }
    
Charmian answered 23/10, 2016 at 13:24 Comment(1)
I've edited attribution into this answer, which I can see is based upon mine (it has the same variable and method names). I'm reasonably liberal about copyright violation and plagiarism, and I guess perhaps you figured that attribution wasn't a big deal here since my answer is literally on the same page, but writing a straight port of code from one language to another without explicitly indicating that that's what it is or linking to the original source (in a context where giving attribution would've been trivial) still seems wrong to me. I've left a -1 on this for that reason.Leyes
A
2

Swift 5.1, Xcode 11

After trying many solutions, I faced issues such as setting correct cursor position and formating as per need, I finally found a solution after combining 2 posts (https://mcmap.net/q/258955/-formatting-a-uitextfield-for-credit-card-input-like-xxxx-xxxx-xxxx-xxxx, https://mcmap.net/q/258955/-formatting-a-uitextfield-for-credit-card-input-like-xxxx-xxxx-xxxx-xxxx)

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    guard let currentText = (textField.text as NSString?)?.replacingCharacters(in: range, with: string) else { return true }


    if textField == yourTextField  {

        textField.setText(to: currentText.grouping(every: 4, with: "-"), preservingCursor: true)

        return false
    }
    return true
}

And adding this extension

extension UITextField {

public func setText(to newText: String, preservingCursor: Bool) {
    if preservingCursor {
        let cursorPosition = offset(from: beginningOfDocument, to: selectedTextRange!.start) + newText.count - (text?.count ?? 0)
        text = newText
        if let newPosition = self.position(from: beginningOfDocument, offset: cursorPosition) {
            selectedTextRange = textRange(from: newPosition, to: newPosition)
        }
    }
    else {
        text = newText
    }
}
Aholla answered 22/7, 2019 at 6:26 Comment(0)
O
1

Here's a swift copy of the accepted answer. It is basically a wrapper class:

var creditCardFormatter : CreditCardFormatter
{
    return CreditCardFormatter.sharedInstance
}

class CreditCardFormatter : NSObject
{
    static let sharedInstance : CreditCardFormatter = CreditCardFormatter()
    
    func formatToCreditCardNumber(textField : UITextField, withPreviousTextContent previousTextContent : String?, andPreviousCursorPosition previousCursorSelection : UITextRange?)
    {
        if let selectedRangeStart = textField.selectedTextRange?.start, textFieldText = textField.text
        {
            var targetCursorPosition : UInt = UInt(textField.offsetFromPosition(textField.beginningOfDocument, toPosition: selectedRangeStart))
            
            let cardNumberWithoutSpaces : String = removeNonDigitsFromString(textFieldText, andPreserveCursorPosition: &targetCursorPosition)
            
            if cardNumberWithoutSpaces.characters.count > 19
            {
                textField.text = previousTextContent
                textField.selectedTextRange = previousCursorSelection
                return
            }
            
            let cardNumberWithSpaces : String = insertSpacesIntoEvery4DigitsIntoString(cardNumberWithoutSpaces, andPreserveCursorPosition: &targetCursorPosition)
            
            textField.text = cardNumberWithSpaces
            
            if let finalCursorPosition = textField.positionFromPosition(textField.beginningOfDocument, offset: Int(targetCursorPosition))
            {
                textField.selectedTextRange = textField.textRangeFromPosition(finalCursorPosition, toPosition: finalCursorPosition)
            }
        }
    }
    
    func removeNonDigitsFromString(string : String,inout andPreserveCursorPosition cursorPosition : UInt) -> String
    {
        var digitsOnlyString : String = ""
        
        for index in 0.stride(to: string.characters.count, by: 1)
        {
            let charToAdd : Character = Array(string.characters)[index]
            
            if isDigit(charToAdd)
            {
                digitsOnlyString.append(charToAdd)
            }
            else
            {
                if index < Int(cursorPosition)
                {
                    cursorPosition -= 1
                }
            }
        }
        
        return digitsOnlyString
    }
    
    private func isDigit(character : Character) -> Bool
    {
        return "\(character)".containsOnlyDigits()
    }
    
    func insertSpacesIntoEvery4DigitsIntoString(string : String, inout andPreserveCursorPosition cursorPosition : UInt) -> String
    {
        var stringWithAddedSpaces : String = ""
        
        for index in 0.stride(to: string.characters.count, by: 1)
        {
            if index != 0 && index % 4 == 0
            {
                stringWithAddedSpaces += " "
                
                if index < Int(cursorPosition)
                {
                    cursorPosition += 1
                }
            }
            
            let characterToAdd : Character = Array(string.characters)[index]
            
            stringWithAddedSpaces.append(characterToAdd)
        }
        
        return stringWithAddedSpaces
    }

}

extension String
{
    func containsOnlyDigits() -> Bool
    {
        let notDigits : NSCharacterSet = NSCharacterSet.decimalDigitCharacterSet().invertedSet
        
        if (rangeOfCharacterFromSet(notDigits, options: NSStringCompareOptions.LiteralSearch, range: nil) == nil)
        {
            return true
        }
        
        return false
    }
}
Overhear answered 7/5, 2016 at 16:29 Comment(2)
-1; you're not setting previousTextContent anywhere, so it receives nil (or, if you make it a String instead of a String?, receives some random garbage bytes). That means that if you overflow 19 characters, the whole text field just gets blanked (or perhaps the app crashes outright if you're unlucky - but so far I've always seen the blanking).Leyes
@MarkAmery I admire your hard work and pedant analysis of each solution from this post :) This as mentioned was just a fast solution which may not account for some edge cases also this encourages people to not just copy paste the solutions found on stack, but also understand and come with improved answers ˆ.ˆ Have a good day as well (;Overhear
C
1

Here is a Kotlin answer based off of Mark Amery:

fun formatCardNumber(cardNumber: String): String {
    var trimmedCardNumber = cardNumber.replace(" ","")

    // UATP cards have 4-5-6 (XXXX-XXXXX-XXXXXX) format
    val is456 = trimmedCardNumber.startsWith("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.
    val is465 = listOf("34", "37", "300", "301", "302", "303", "304", "305", "309", "36", "38", "39")
            .any { trimmedCardNumber.startsWith(it) }

    // In all other cases, assume 4-4-4-4.
    val is4444 = !(is456 || is465)

    trimmedCardNumber = if (is456 || is465) {
         trimmedCardNumber.take(cardNumberMaxLengthAmex)
    } else {
         trimmedCardNumber.take(cardNumberMaxLength)
    }

    var cardNumberWithAddedSpaces = ""

    trimmedCardNumber.forEachIndexed { index, c ->
        val needs465Spacing = is465 && (index == 4 || index == 10 || index == 15)
        val needs456Spacing = is456 && (index == 4 || index == 9 || index == 15)
        val needs4444Spacing = is4444 && index > 0 && index % 4 == 0

        if (needs465Spacing || needs456Spacing || needs4444Spacing) {
            cardNumberWithAddedSpaces += " "
        }

        cardNumberWithAddedSpaces += c
    }

    return cardNumberWithAddedSpaces
}

Then add a text changed listener on an edit text:

var flag = false

editText.addTextChangedListener(object : TextWatcher {
        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            if (flag) { 
                flag = false 
            } else {
                val text = formatCardNumber(s.toString())
                flag = true
                editText.setText(text)
                editText.setSelection(text.count())
            }
        }

        override fun afterTextChanged(s: Editable?) {}
    })
Caraway answered 5/7, 2018 at 18:7 Comment(0)
D
1

You can use my simple library: DECardNumberFormatter

Example:

// You can use it like default UITextField
let textField = DECardNumberTextField()
// Custom required setup
textField.setup()

Output:

For sample card number (Visa) 4111111111111111
Format (4-4-4-4): 4111 1111 1111 1111

For sample card number (AmEx) 341212345612345
Format (4-6-5): 3412 123456 12345
Denisdenise answered 16/3, 2020 at 21:49 Comment(0)
L
0

Check Out This Solution. I found in Autorize.net SDK Example.

Make Your UITextField Keyboard Type to Numeric.

It Will Mask Credit Card Numbers With 'X' And By Adding Spaces It Will Make 'XXXX XXXX XXXX 1234' format.

In Header .h file

    #define kSpace @" "
    #define kCreditCardLength 16
    #define kCreditCardLengthPlusSpaces (kCreditCardLength + 3)
    #define kCreditCardObscureLength (kCreditCardLength - 4)

    @property (nonatomic, strong) NSString *creditCardBuf;
    IBOutlet UITextField *txtCardNumber;

In .m file

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
{
    if (textField == txtCardNumber) {
        if ([string length] > 0) { //NOT A BACK SPACE Add it

            if ([self isMaxLength:textField])
                return NO;

            self.creditCardBuf  = [NSString stringWithFormat:@"%@%@", self.creditCardBuf, string];
        } else {

            //Back Space do manual backspace
            if ([self.creditCardBuf length] > 1) {
                self.creditCardBuf = [self.creditCardBuf substringWithRange:NSMakeRange(0, [self.creditCardBuf length] - 1)];
            } else {
                self.creditCardBuf = @"";
            }
        }
        [self formatValue:textField];
    }

    return NO;
}

- (BOOL) isMaxLength:(UITextField *)textField {

    if (textField == txtCardNumber && [textField.text length] >= kCreditCardLengthPlusSpaces) {
        return YES;
    }
    return NO;
}

- (void) formatValue:(UITextField *)textField {
    NSMutableString *value = [NSMutableString string];

    if (textField == txtCardNumber) {
        NSInteger length = [self.creditCardBuf length];

        for (int i = 0; i < length; i++) {

            // Reveal only the last character.
            if (length <= kCreditCardObscureLength) {

                if (i == (length - 1)) {
                    [value appendString:[self.creditCardBuf substringWithRange:NSMakeRange(i,1)]];
                } else {
                    [value appendString:@“X”];
                }
            }
            // Reveal the last 4 characters
            else {

                if (i < kCreditCardObscureLength) {
                    [value appendString:@“X”];
                } else {
                    [value appendString:[self.creditCardBuf substringWithRange:NSMakeRange(i,1)]];
                }
            }

            //After 4 characters add a space
            if ((i +1) % 4 == 0 &&
                ([value length] < kCreditCardLengthPlusSpaces)) {
                [value appendString:kSpace];
            }
        }
        textField.text = value;
    }
}
Leckie answered 30/4, 2015 at 7:55 Comment(1)
-1; this is hopelessly broken. Leaving aside the magic quotes here that make it a syntax error - since those at least I can easily fix - when I first type, it inserts (masked with Xs) the string (nul l) into the start of the text field, and if I type a character in the middle of the card number, it jumps my cursor to the end.Leyes
E
0

Here's a solution in Swift 2.2.1

extension UITextField {

    func setText(to newText: String, preservingCursor: Bool) {
        if preservingCursor {
            let cursorPosition = offsetFromPosition(beginningOfDocument, toPosition: selectedTextRange!.start) + newText.characters.count - (text?.characters.count ?? 0)
            text = newText
            if let newPosition = positionFromPosition(beginningOfDocument, offset: cursorPosition) {
                selectedTextRange = textRangeFromPosition(newPosition, toPosition: newPosition)
            }
        }
        else {
            text = newText
        }
    }
}

Now just put an IBAction in your view controller:

@IBAction func textFieldEditingChanged(sender: UITextField) {
    var digits = current.componentsSeparatedByCharactersInSet(NSCharacterSet.decimalDigitCharacterSet().invertedSet).joinWithSeparator("") // remove non-digits
    // add spaces as necessary or otherwise format your digits.
    // for example for a phone number or zip code or whatever
    // then just:
    sender.setText(to: digits, preservingCursor: true)
}
Epicycloid answered 8/8, 2016 at 21:24 Comment(1)
-1; leaving aside that this leaves a bunch of work to the reader (and doesn't compile as it stands; what's current meant to be?), this doesn't preserve the cursor position properly. e.g. if I have 1234 5678 in the text field and type a 0 after the 4, I end up with 1234 5678 0 but with my cursor before the 0 I just typed, instead of after it.Leyes
O
0

Please use simple form of credite card /** See sample usage: ### let str = "41111111111111111"

 let x = yourClassname.setStringAsCardNumberWithSartNumber(4, withString: str!, withStrLenght: 8)
 
 ### output:- 4111XXXXXXXX1111
 
 let x = yourClassname.setStringAsCardNumberWithSartNumber(0, withString: str!, withStrLenght: 12)
 
 ### output: - XXXXXXXXXXXX1111
 
 */
func setStringAsCardNumberWithSartNumber(Number:Int,withString str:String ,withStrLenght len:Int ) -> String{
    //let aString: String = "41111111111111111"
    let arr = str.characters
    var CrediteCard : String = ""
    if arr.count > (Number + len) {
        for (index, element ) in arr.enumerate(){
            if index >= Number && index < (Number + len) {
                CrediteCard = CrediteCard + String("X")
            }else{
                CrediteCard = CrediteCard + String(element)
            }
        }
      return CrediteCard
    }else{
            print("\(Number) plus \(len) are grether than strings chatarter \(arr.count)")
    }
    print("\(CrediteCard)")
    return str
}
Oliva answered 13/9, 2016 at 14:4 Comment(1)
-1 for the misspellings and commented code; they don't exactly inspire confidence that this is going to do anything sensible.Leyes
S
0

Please check bellow solution, its working fine for me-

func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool {

        let subString = (textField.text as! NSString).substringWithRange(range)
        if subString == " " && textField == cardNumberTextfield
        {
            return false     // user should not be able to delete space from card field
        }
        else if string == ""
        {
            return true      // user can delete any digit
        }


        // Expiry date formatting

        if textField == expiryDateTextfield
        {
            let str = textField.text! + string

            if str.length == 2 && Int(str) > 12
            {
                return false                  // Month should be <= 12
            }
            else if str.length == 2
            {
                textField.text = str+"/"      // append / after month
                return false
            }
            else if str.length > 5
            {
                return false                  // year should be in yy format
            }
        }



        // Card number formatting

        if textField == cardNumberTextfield
        {
            let str = textField.text! + string

            let stringWithoutSpace = str.stringByReplacingOccurrencesOfString(" ", withString: "")

            if stringWithoutSpace.length % 4 == 0 && (range.location == textField.text?.length)
            {
                if stringWithoutSpace.length != 16
                {
                    textField.text = str+" "    // add space after every 4 characters
                }
                else
                {
                    textField.text = str       // space should not be appended with last digit
                }

                return false
            }
            else if str.length > 19
            {
                return false
            }
        }



        return true
    }
Spooky answered 20/9, 2016 at 7:38 Comment(1)
-1; leaving aside the post's broken formatting and English and the fact that there's a load of irrelevant code here about expiry dates, this doesn't work. If you move the text cursor to anywhere other than the rightmost point in the text field and type, the formatting gets broken.Leyes
B
0

i modified @ilesh answer so it only shows the last 4 digits no matter what the lenght is. Also to ignore the space and "-" chars. This way, if we have a number with the format 0000 - 0000 - 0000 - 0000 it displays XXXX - XXXX - XXXX - 0000

func setStringAsCardNumberWithSartNumber(Number:Int,withString str:String) -> String{
    let arr = str.characters
    var CrediteCard : String = ""
    let len = str.characters.count-4
    if arr.count > (Number + len) {
        for (index, element ) in arr.enumerated(){
            if index >= Number && index < (Number + len) && element != "-" && element != " " {
                CrediteCard = CrediteCard + String("X")
            }else{
                CrediteCard = CrediteCard + String(element)
            }
        }
        return CrediteCard
    }else{
        print("\(Number) plus \(len) are grether than strings chatarter \(arr.count)")
    }
    print("\(CrediteCard)")
    return str
}
Bemused answered 13/11, 2016 at 16:40 Comment(1)
-1 for the lack of usage instructions and the broken English. This answer, like ilesh's, doesn't even mention UITextFields.Leyes
J
0

Here is the modification of the answer from @sleeping_giant for swift. This solution formats the text in xxxx-xxxx-xxxx-xxxx-xxxx format and stops accepting any numbers beyond that range:

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool
{
    if string == ""{
        return true
    }
    
    //range.length will be greater than 0 if user is deleting text - allow it to replace
    if range.length > 0
    {
        return true
    }
    
    //Don't allow empty strings
    if string == "-"
    {
        return false
    }
    
    //Check for max length including the spacers we added
    print(range.location)
    if range.location > 23
    {
        return false
    }
    
    var originalText = textField.text
    let replacementText = string.replacingOccurrences(of: "-", with: "")
    
    //Verify entered text is a numeric value
    let digits = NSCharacterSet.decimalDigits
    for char in replacementText.unicodeScalars
    {
        if !(digits as NSCharacterSet).longCharacterIsMember(char.value)
        {
            return false
        }
    }
    
    //Put an empty space after every 4 places
    if (originalText?.characters.count)! > 0
    {
        if (originalText?.characters.count)! < 5 && (originalText?.characters.count)! % 4 == 0{
            originalText?.append("-")
        }else if(((originalText?.characters.count)! + 1) % 5 == 0){
            originalText?.append("-")
        }
        
    }
    
    textField.text = originalText
    
    return true
}
Judithjuditha answered 19/12, 2016 at 9:17 Comment(1)
-1; like many of the answers here, this completely breaks if I move my text cursor to the left of the text field and insert some digits there.Leyes
S
0

Found a GIST in Github that does exactly what I need in Swift3 (https://gist.github.com/nunogoncalves/6a8b4b21f4f69e0fc050190df96a1e56)

Implemented by doing ->

if creditCardNumberTextView.text?.characters.first == "3" {
    let validator = Validator(cardType: .americanExpress, value:  self.creditCardNumberTextView.text!).test()

      if validator == true {

       } else {

       }
   }

Works wonderfully in the APP I'm working out which uses credit cards.

Sprouse answered 20/2, 2017 at 21:30 Comment(1)
-1; that Gist is for validating card numbers, not for formatting them. This is not an answer to the question that was asked.Leyes
B
0

In my case, we have to formatting IBAN number. I think, the below code block help you

Firstly, check the user enterted value is valid:

-(BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string{

    if(textField == self.ibanTextField){
        
           BOOL shouldChange =  ([Help checkTextFieldForIBAN:[NSString stringWithFormat:@"%@%@",textField.text,string]]);
 }
}

Secondly, you can see iban formated method just like below. Our IBAN formatted begin 2 letter.

+(BOOL)checkTextFieldForIBAN:(NSString*)string{
    
    string = [string stringByReplacingOccurrencesOfString:@" " withString:@""];
    
    if ([string length] <= 26) {
        
        if ([string length] > 2) {
            
            if ([self isLetter:[string substringToIndex:2]]) {
                
                if ([self isInteger:[string substringFromIndex:2]])
                    return YES;
                else
                    return NO;
                
            }else {
                
                return NO;
            }
        }else{
            
            return [self isLetter:string];
        }
        
    }
    else {
        
        return NO;
    }
    
    return YES;
}
Belabor answered 21/2, 2017 at 5:42 Comment(0)
O
0

Create new swift file and paste below code, change text field class to VSTextField

import UIKit

public enum TextFieldFormatting {
    case uuid
    case socialSecurityNumber
    case phoneNumber
    case custom
    case noFormatting
}

public class VSTextField: UITextField {
    
    /**
     Set a formatting pattern for a number and define a replacement string. For example: If formattingPattern would be "##-##-AB-##" and
     replacement string would be "#" and user input would be "123456", final string would look like "12-34-AB-56"
     */
    public func setFormatting(_ formattingPattern: String, replacementChar: Character) {
        self.formattingPattern = formattingPattern
        self.replacementChar = replacementChar
        self.formatting = .custom
    }
    
    /**
     A character which will be replaced in formattingPattern by a number
     */
    public var replacementChar: Character = "*"
    
    /**
     A character which will be replaced in formattingPattern by a number
     */
    public var secureTextReplacementChar: Character = "\u{25cf}"
    
    /**
     True if input number is hexadecimal eg. UUID
     */
    public var isHexadecimal: Bool {
        return formatting == .uuid
    }
    
    /**
     Max length of input string. You don't have to set this if you set formattingPattern.
     If 0 -> no limit.
     */
    public var maxLength = 0
    
    /**
     Type of predefined text formatting. (You don't have to set this. It's more a future feature)
     */
    public var formatting : TextFieldFormatting = .noFormatting {
        didSet {
            switch formatting {
                
            case .socialSecurityNumber:
                self.formattingPattern = "***-**-****"
                self.replacementChar = "*"
                
            case .phoneNumber:
                self.formattingPattern = "***-***-****"
                self.replacementChar = "*"
                
            case .uuid:
                self.formattingPattern = "********-****-****-****-************"
                self.replacementChar = "*"
                
            default:
                self.maxLength = 0
            }
        }
    }
    
    /**
     String with formatting pattern for the text field.
     */
    public var formattingPattern: String = "" {
        didSet {
            self.maxLength = formattingPattern.count
        }
    }
    
    /**
     Provides secure text entry but KEEPS formatting. All digits are replaced with the bullet character \u{25cf} .
     */
    public var formatedSecureTextEntry: Bool {
        set {
            _formatedSecureTextEntry = newValue
            super.isSecureTextEntry = false
        }
        
        get {
            return _formatedSecureTextEntry
        }
    }
    
    override public var text: String! {
        set {
            super.text = newValue
            textDidChange() // format string properly even when it's set programatically
        }
        
        get {
            if case .noFormatting = formatting {
                return super.text
            } else {
                // Because the UIControl target action is called before NSNotificaion (from which we fire our custom formatting), we need to
                // force update finalStringWithoutFormatting to get the latest text. Otherwise, the last character would be missing.
                textDidChange()
                return finalStringWithoutFormatting
            }
        }
    }
    
    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        registerForNotifications()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        registerForNotifications()
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
    
    /**
     Final text without formatting characters (read-only)
     */
    public var finalStringWithoutFormatting : String {
        return _textWithoutSecureBullets.keepOnlyDigits(isHexadecimal: isHexadecimal)
    }
    
    // MARK: - INTERNAL
    fileprivate var _formatedSecureTextEntry = false
    
    // if secureTextEntry is false, this value is similar to self.text
    // if secureTextEntry is true, you can find final formatted text without bullets here
    fileprivate var _textWithoutSecureBullets = ""
    
    fileprivate func registerForNotifications() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(VSTextField.textDidChange),
                                               name: NSNotification.Name(rawValue: "UITextFieldTextDidChangeNotification"),
                                               object: self)
    }
    
    @objc public func textDidChange() {
        var superText: String { return super.text ?? "" }
        
        // TODO: - Isn't there more elegant way how to do this?
        let currentTextForFormatting: String
        
        if superText.count > _textWithoutSecureBullets.count {
            currentTextForFormatting = _textWithoutSecureBullets + superText[superText.index(superText.startIndex, offsetBy: _textWithoutSecureBullets.count)...]
        } else if superText.count == 0 {
            _textWithoutSecureBullets = ""
            currentTextForFormatting = ""
        } else {
            currentTextForFormatting = String(_textWithoutSecureBullets[..<_textWithoutSecureBullets.index(_textWithoutSecureBullets.startIndex, offsetBy: superText.count)])
        }
        
        if formatting != .noFormatting && currentTextForFormatting.count > 0 && formattingPattern.count > 0 {
            let tempString = currentTextForFormatting.keepOnlyDigits(isHexadecimal: isHexadecimal)
            
            var finalText = ""
            var finalSecureText = ""
            
            var stop = false
            
            var formatterIndex = formattingPattern.startIndex
            var tempIndex = tempString.startIndex
            
            while !stop {
                let formattingPatternRange = formatterIndex ..< formattingPattern.index(formatterIndex, offsetBy: 1)
                if formattingPattern[formattingPatternRange] != String(replacementChar) {
                
                    finalText = finalText + formattingPattern[formattingPatternRange]
                    finalSecureText = finalSecureText + formattingPattern[formattingPatternRange]
                    
                } else if tempString.count > 0 {
                    
                    let pureStringRange = tempIndex ..< tempString.index(tempIndex, offsetBy: 1)
                    
                    finalText = finalText + tempString[pureStringRange]
                    
                    // we want the last number to be visible
                    if tempString.index(tempIndex, offsetBy: 1) == tempString.endIndex {
                        finalSecureText = finalSecureText + tempString[pureStringRange]
                    } else {
                        finalSecureText = finalSecureText + String(secureTextReplacementChar)
                    }
                    
                    tempIndex = tempString.index(after: tempIndex)
                }
                
                formatterIndex = formattingPattern.index(after: formatterIndex)
                
                if formatterIndex >= formattingPattern.endIndex || tempIndex >= tempString.endIndex {
                    stop = true
                }
            }
            
            _textWithoutSecureBullets = finalText
            
            let newText = _formatedSecureTextEntry ? finalSecureText : finalText
            if newText != superText {
                super.text = _formatedSecureTextEntry ? finalSecureText : finalText
            }
        }
        
        // Let's check if we have additional max length restrictions
        if maxLength > 0 {
            if superText.count > maxLength {
                super.text = String(superText[..<superText.index(superText.startIndex, offsetBy: maxLength)])
                _textWithoutSecureBullets = String(_textWithoutSecureBullets[..<_textWithoutSecureBullets.index(_textWithoutSecureBullets.startIndex, offsetBy: maxLength)])
            }
        }
    }
}


extension String {
    
    func keepOnlyDigits(isHexadecimal: Bool) -> String {
        let ucString = self.uppercased()
        let validCharacters = isHexadecimal ? "0123456789ABCDEF" : "0123456789"
        let characterSet: CharacterSet = CharacterSet(charactersIn: validCharacters)
        let stringArray = ucString.components(separatedBy: characterSet.inverted)
        let allNumbers = stringArray.joined(separator: "")
        return allNumbers
    }
}


// Helpers
fileprivate func < <T: Comparable>(lhs: T?, rhs: T?) -> Bool {
    switch (lhs, rhs) {
    case let (l?, r?):
        return l < r
    case (nil, _?):
        return true
    default:
        return false
    }
}

fileprivate func > <T: Comparable>(lhs: T?, rhs: T?) -> Bool {
    switch (lhs, rhs) {
    case let (l?, r?):
        return l > r
    default:
        return rhs < lhs
    }
}

More uses will be found on below links:

Obstacle answered 23/5, 2018 at 12:36 Comment(2)
-1; besides the fact that the code here is copied and pasted from a library without being clearly marked as such, it also doesn't answer the question (or even mention credit cards, actually) and the library you copied it from has significant bugs that my answer does not have.Leyes
@MarkAmery You have rights to downvote. But recently I have used this library and found helpful. May someone else will find this helpful.Obstacle
A
0

You can use StringPatternFormatter pod:

pod 'StringPatternFormatter'

The UITextField need to implement the following method from UITextFieldDelegate:

import StringPatternFormatter

...

textField.delegate = self

...

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    guard let text = textField.text else {
        return true
    }
    
    let lastText = (text as NSString).replacingCharacters(in: range, with: string) as String
        
    textField.text = lastText.format("nnnn nnnn nnnn nnnn", oldString: text)
    return false
}
Ambert answered 11/2, 2022 at 15:38 Comment(0)
S
0

Answer of @jayant give me base for what I was looking for, however there is no limitation for card length which is 16.

So updated answer with 16 digit card number is as below.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {

    let textFieldText = textField.text as NSString?
    let value = textFieldText?.replacingCharacters(in: range, with: string) ?? ""

    // here is update I make
    // so what I check if the new text is beyond 16 (after removing spaces), 
    // use textfield text as it is else use new text
    let finalText : String = ((value.replacingOccurrences(of: " ", with: "").count > 16) ? (textFieldText ?? "") as String : value)
    textField.setText(to: finalText.grouping(every: 4, with: " "), preservingCursor: true)
    return false
}

Rest code as above

extension UITextField {
    public func setText(to newText: String, preservingCursor: Bool) {
        if preservingCursor {
            let cursorPosition = offset(from: beginningOfDocument, to: selectedTextRange!.start) + newText.count - (text?.count ?? 0)
            text = newText
            if let newPosition = self.position(from: beginningOfDocument, offset: cursorPosition) {
                selectedTextRange = textRange(from: newPosition, to: newPosition)
            }}
        else{
            text = newText
        }
    }
}

extension String {
    
    func grouping(every groupSize: String.IndexDistance, with separator: Character) -> String {
        let cleanedUpCopy = replacingOccurrences(of: String(separator), with: "")
        return String(cleanedUpCopy.enumerated().map() {
            $0.offset % groupSize == 0 ? [separator, $0.element] : [$0.element]
        }.joined().dropFirst())
    }
}
Sable answered 9/7 at 11:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.