类似于(xxxx xxxx xxxx xxxx)的格式化UITextField以便输入信用卡信息

80

我想格式化一个UITextField用于输入信用卡号码,只允许输入数字并自动插入空格,使得卡号的格式如下:

XXXX XXXX XXXX XXXX

我该怎么做?


1
如果您可以使用开源库,我强烈建议您查看PaymentKit(https://github.com/stripe/PaymentKit)。它们有一个您可以使用的格式化程序,并且适用于各种卡片(还具有Luhn检查和其他验证器)。 - Mike Welsh
@MikeWelsh 的方法很有趣,也许比我的回答更好,但我没有时间或意愿去研究它(特别是我不再拥有 Mac,并且已经一年多没有进行 iOS 开发了)。如果您有使用该库的经验,那么编写一个展示如何使用它的简单示例答案,可能会对未来的读者有更大的价值,而不仅仅是留下评论。 - Mark Amery
如果您正在寻找动态方法,这个答案可能会有所帮助。 https://dev59.com/NlrUa4cB1Zd3GeqPfwAI#38560759 - Tesan3089
1
这个问题一直吸引着那些认为他们通过提供比我的(被接受的)答案更短、更简单的答案来帮助的人回答。这些答案确实更短、更简单,但是作为一个结果,它们中没有一个能够工作!(而且是的,我亲自测试了每一个。)这是一个看似困难的问题,人们!如果你要尝试提供一个更好的答案,至少阅读我的答案中的“解释”部分和我留下的许多评论,解释其他人的实现方式存在的问题,并检查你是否没有以同样的方式失败。 - Mark Amery
如果您想要一种简洁的解决方案,并且是用 Swift 语言编写的,那么这个答案将会对您有所帮助。 https://dev59.com/y5bfa4cB1Zd3GeqP1v8e#46096440 - Teena nath Paul
30个回答

147
如果您正在使用Swift,请阅读我为Swift 4端口的这个答案,并使用该端口替代。
如果您正在使用Objective-C...
首先,向您的UITextFieldDelegate添加这些实例变量...
NSString *previousTextFieldContent;
UITextRange *previousSelection;

...和这些方法:

// Version 1.3
// Source and explanation: https://dev59.com/E2ct5IYBdhLWcg3wc9Eg#19161529
-(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;
}

其次,将reformatCardNumber:设置为在文本字段触发UIControlEventEditingChanged事件时调用:
[yourTextField addTarget:yourTextFieldDelegate 
                             action:@selector(reformatAsCardNumber:)
                   forControlEvents:UIControlEventEditingChanged];

当然,在您的文本字段及其委托被实例化之后,您需要在某个时间点执行此操作。如果您正在使用storyboards,则视图控制器的viewDidLoad方法是一个适当的位置。
一些解释:
这是一个看似复杂的问题。可能不会立即显而易见的三个重要问题(以及此前答案中所有未考虑的问题):
  1. 虽然信用卡和借记卡号码的XXXX XXXX XXXX XXXX格式是最常见的,但并不是唯一的。例如,美国运通卡通常采用15位数字,以XXXX XXXXXX XXXXX的格式书写,就像这样:

    An American Express card

    甚至Visa卡也可以少于16位数字,而Maestro卡可以有更多的数字:

    A Russian Maestro card with 18 digits

  2. 用户与文本字段交互的方式不仅仅是在现有输入的末尾键入单个字符。您还必须正确处理用户在字符串中间添加字符、删除单个字符、删除多个选定字符和粘贴多个字符的情况。对于这个问题来说,一些更简单/更幼稚的方法将无法正确处理其中的一些交互。最恶劣的情况是用户在字符串中间粘贴多个字符以替换其他字符,而此解决方案足以处理这种情况。

  3. 在用户修改文本字段的文本后,您不仅需要正确重新格式化文本字段的文本,还需要合理地定位文本光标。不考虑这一点的问题的幼稚方法几乎肯定会在某些情况下做一些愚蠢的事情,比如在用户在其中添加数字后将其放置到文本字段的末尾。

为了解决问题#1,我们使用由Baymard研究所精选的信用卡号码前缀部分映射到格式的部分映射,链接在https://baymard.com/checkout-usability/credit-card-patterns。我们可以从前几个数字自动检测到信用卡提供商,并且(在某些情况下)推断出格式并相应地调整我们的格式。感谢cnotethegr8为本答案做出贡献。
处理问题#2的最简单和最容易的方法(也是上面代码中使用的方法)是剥离所有空格,并在文本字段内容更改时重新插入它们到正确的位置,这样就不需要弄清楚正在进行何种文本操作(插入、删除或替换)并以不同的方式处理可能性。
为了解决问题#3,我们跟踪光标所需的索引如何随着去除非数字并插入空格而更改。这就是为什么代码使用NSMutableString进行逐个字符的操作,而不是使用NSString的字符串替换方法。最后,还有一个陷阱:从textField:shouldChangeCharactersInRange:replacementString返回NO会破坏用户在文本字段中选择文本时得到的“剪切”按钮,这就是我不这样做的原因。从该方法返回NO会导致“剪切”根本不更新剪贴板,我不知道任何修复或解决方法。因此,我们需要在UIControlEventEditingChanged处理程序中重新格式化文本字段,而不是在shouldChangeCharactersInRange:本身中(更明显地)进行。幸运的是,UIControl事件处理程序似乎在UI更新被刷新到屏幕之前被调用,因此这种方法很好地工作。此外,还有一堆关于文本字段应该如何行事的小问题,没有明显的正确答案:
  • 如果用户试图粘贴一些内容,导致文本字段的内容超过19个数字,是应该插入所粘贴字符串的开头(直到达到19个数字),然后截断其余部分,还是根本不插入任何内容?
  • 如果用户尝试通过将光标定位在空格之后并按下退格键来删除单个空格,是应该什么都不发生并保持光标在原地,还是应该将光标向左移动一个字符(使其位于空格之前),或者应该像光标已经在空格左侧一样删除空格左侧的数字?
  • 当用户输入第四、第八或第十二个数字时,是否应立即插入一个空格并将光标移到其后面,还是只有在用户输入第五、第九或第十三个数字后才插入空格?
  • 当用户删除空格后的第一个数字时,如果这不会完全删除空格,这是否会导致光标被定位在空格之前还是之后?

可能对于这些问题的任何答案都是足够的,但我列出它们只是为了明确,在这里你可能需要仔细考虑很多特殊情况,如果你够着迷的话。在上面的代码中,我选择了我认为合理的这些问题的答案。如果你对其中任何一点有强烈的感受,而这些感受与我的代码行为不兼容,那么调整它以满足你的需求应该很容易。


1
我在执行此操作时遇到了“向实例发送未识别的选择器”和线程问题。有什么想法吗? - Jordan Feldstein
我用这个差异(diff)修复了它:http://cl.ly/image/45182G0Z3r1O 在控制器上存储引用似乎可以防止它被垃圾回收,这正是最初产生错误的原因。希望这有所帮助!如果@MarkAmery想要审查和更新他的解决方案,请告知他。 - Jordan Feldstein
@JordanFeldsteint 很高兴你解决了问题。你所描述的(必须保留对象引用以避免它们被垃圾回收)是Objective-C中ARC的标准问题,超出了本答案的范围。顺便说一句,不得不进行这种繁琐的簿记的麻烦之一是许多人(包括我和包括苹果开发人员在演示中)喜欢将他们的视图控制器作为所有内容的代表,而不是创建其他对象用作代表。在Xcode中使用pragma marks可以使复杂视图的管理变得容易。 - Mark Amery
@MarkAmery 我已经按照你的想法实现了一个小型库来管理文本字段格式 https://github.com/chebur/CHRTextFieldFormatter - chebur
@MarkAmery,你在一开始计算targetCursorPosition的方式是错误的。尝试添加一个不是Unicode标量的字符,比如表情符号。这样会导致光标位置不准确。 - HHK
@HHK 嗯,这里没问题,因为我们将输入限制为 ASCII 数字,但对于任何可以输入任何 Unicode 字符的字段尝试适应该技术的人来说,这是一个值得注意的陷阱。 - Mark Amery

40
以下是一个工作中的Swift 4版本Logicopolis's answer(它本身是Objective-C的accepted answer的Swift 2版本),增强了cnotethegr8的技巧以支持美国运通卡和更多的卡格式。如果您还没有看过已接受的答案,我建议您先查看一下,因为它有助于解释很多这个代码背后的动机。
请注意,看到此操作的最小步骤系列是:
1. 在Swift中创建一个新的“Single View App”。 2. 在Main.storyboard上添加一个“Text Field”。 3. 将ViewController设置为Text Field的委托。 4. 将下面的代码粘贴到ViewController.swift中。 5. 连接IBOutletText Field。 6. 运行您的应用程序并在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
    }
}

将这个适应到其他情况,如您的委托不是ViewController,留给读者作为练习。


如果我使用DispatchQueue.main.async将textField.selectedTextRange = textField.textRange(from: targetPosition, to: targetPosition)分派到主线程,它才能正常工作。我猜在这一点上textview处于某种状态,导致textField.selectedTextRange被忽略了。重新分派可以解决这个问题。 - scrrr
1
我认为这可能会崩溃。输入4111111111111111111(应该看起来像4111 1111 1111 1111 111),退格删除最后三位数字,摇晃以撤消并选择撤消。*** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[NSBigMutableString substringWithRange:]: Range {20, 0} out of bounds; string length 19' - Josh Paradroid
@MarkAmery 对于我目前使用的情况,抑制它就足够了,但我目前并没有做到这一点。感谢您对一个旧问题的快速回复。 - Josh Paradroid
2
reformatAsCardNumber 结束时加上 textField.undoManager?.removeAllActions() 可以防止崩溃发生。虽然不是最好的解决方法,但它可以解决问题。 - Josh Paradroid
@MarkAmery 假设我想在输入时格式化卡号,例如 4012 0*** ***3 0026,我们应该如何处理这个现有的解决方案? - Jarvis The Avenger
显示剩余2条评论

26

你可能能够优化我的代码,或者可能有更简单的方法,但是这段代码应该可以工作:

-(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;
}

1
这个解决方案至少存在一个重大缺陷:如果我输入了“1234”,然后将文本光标移动到“1”的后面,然后键入或删除一个字符,那么我的文本光标会突然跳回到文本字段的末尾。 - Mark Amery
这会破坏存储的文本吗?还是只影响显示的文本? - James Campbell

13

使用Fawkes的答案作为基础的Swift 3解决方案。 新增Amex卡片格式支持。 当卡片类型改变时,新增重组。

首先用以下代码创建新类:

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
    }

}

在您的ViewControllerClass中添加此函数。

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)
}

然后将目标添加到您的textField中

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

注册新变量并将卡类型发送到该变量

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

感谢Fawkes提供的代码!


1
withPreviousTextContent的值不正确。 - Aliaksandr Bialiauski
-1;这样做不能正确处理删除空格后的数字。如果我的文本字段中有 1234 5678 9012,我将光标定位在 9 后面并按退格键,则 9 将被删除,但我的文本光标最终会停留在 8 后面而不是 0 后面。 - Mark Amery

12

我认为这个不错:

-(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;
    }

1
这个答案也有问题。如果我输入数字后回去删除字符,空格的位置就会出错。 - Mark Amery
1
如果这样写会出问题吗?if ([string length] == 0) return YES; - Lucas
是的 - 这里还有很多问题。首先,只要我将文本光标移动到框的左侧,我就可以输入任意长的数字! - Mark Amery

10

所以我希望用更少的代码来完成这个任务,于是我使用了这里的代码,并加以修改。屏幕上有两个字段,一个是数字,一个是到期日期,因此我让它更具可重用性。

Swift 3 替代方案

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())
    }
}

每个人都希望用更少的代码来完成这个任务,但到目前为止还没有人成功。这个答案(像大多数其他答案一样)存在一个问题,而我在我的答案中已经警告过了:如果你将文本光标移动到文本字段的其他位置并键入数字,文本光标会跳到文本字段的末尾,这是不应该发生的。 - Mark Amery

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) 中的“-”可以改成你想要的任何其他字符。


-1;和其他答案一样,如果移动文本光标,它的行为会变得非常糟糕。每次我输入一个数字,光标都会跳到文本字段的右侧,无论我在哪里输入数字。 - Mark Amery
它对我有效,但将其转换为最新的Swift代码。 - Shanu Singh

8
在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
        }
    }

7

这里是 Swift 5 版本的 Mark Amery所接受的答案

在你的类中添加这些变量:

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

同时确保你的文本字段调用 reformatAsCardNumber :从 viewDidLoad() :。
cardNumberTextField.addTarget(self, action: #selector(reformatAsCardNumber), for: .editingChanged)

在您的UITextFieldDelegate中添加以下内容:

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

最后,在您的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
}

5

以下是针对Swift 2的已接受答案的另一个版本...

请确保在您的代理实例中包含以下内容:

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

同时确保您的文本字段调用reformatAsCardNumber:
textField.addTarget(self, action: #selector(reformatAsCardNumber(_:)), forControlEvents: .EditingChanged)

您的文本字段委托将需要执行以下操作:
func textField(textField: UITextField, shouldChangeCharactersInRange 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.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
}

2
干得好 - 这是我回答的唯一一个实际可用的Swift转换;事实上,除了我的之外,在这个问题吸引的惊人的27个(大多数是垃圾)答案中,这是唯一一个可用的答案。我已经编辑了这个内容以说明它适用于Swift 2,并且还将其用作我自己的Swift 4端口的基础。只是想说声谢谢并让你知道! - Mark Amery

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接