类似于(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个回答

4

如果有人仍在寻找此答案但使用Swift而非Objective-C,以下是Swift版本。无论如何,概念都是相同的。

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
}

好的代码。对我来说很有效,但有一个问题。它会在字符串开头放置空格。例如,如果我想写 4242424242424242,则此字符串的输出将为“ 4242 4242 4242 4242”。 - Birju
除了@Birju提到的开头空格外,如果我将文本光标移动到字符串中较早的位置,这也会导致其破损;如果我在那里输入,它不仅会打破4个数字块之间的间距,还会让我超出字符限制。 - Mark Amery

4

Swift 3.2

@Lucas的回答有一点错误,在Swift 3.2中,以下是可以工作的代码,并自动删除空格。

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
}

1
-1;这完全有问题。如果我将光标移动到文本字段的任何位置并键入,就会破坏格式并违反长度限制。而且,如果我将光标移到不在文本字段末尾的位置并删除字符,那么就会发生非常奇怪的事情;整个字符块被咬掉了。 - Mark Amery

4
为了实现在文本字段中以这种方式格式化输入的文本的目标,需要牢记一些重要事项。除了16位卡号每四位数字分隔是最常用的格式之外,还有15位(AmEx格式为XXXX XXXXXX XXXXX)和13位甚至19位的卡片(https://en.wikipedia.org/wiki/Payment_card_number)。另一个重要的事情是配置textField只允许数字,将键盘类型配置为numberPad是个好的开始,但最好实现一个方法来保证输入的安全。
决定何时格式化数字是一个起点,当用户输入数字或者当用户离开文本字段时。 如果你想在用户离开textField时进行格式化,则方便使用textFieldDidEndEditing(_:)委托方法获取textField的内容并进行格式化。
在用户输入数字时,有用的是textField(_:shouldChangeCharactersIn:replacementString:)委托方法,该方法在当前文本更改时调用。
在这两种情况下,仍然存在一个问题,就是要确定输入数字的正确格式。根据我所见到的所有数字,我认为只有两种主要格式:上述带有15位数字的美国运通卡格式和将卡号每四个数字分组的格式,不关心有多少位数字,这种情况就像一个通用规则,例如13位数字的卡片将被格式化为XXXXX XXXX XXXX X,而19位数字的卡片将看起来像这样XXXX XXXX XXXX XXXX XXX,这将适用于最常见的情况(16位数字)以及其他情况。因此,您可以通过使用下面相同的算法来处理AmEx情况,并玩弄魔术数字来解决问题。
我使用了正则表达式来确保15位数字卡片是美国运通卡,在其他特定格式的情况下。
let regex = NSPredicate(format: "SELF MATCHES %@", "3[47][A-Za-z0-9*-]{13,}" )
let isAmex = regex.evaluate(with: stringToValidate)

我强烈建议使用特定的正则表达式来识别发卡行并确定可接受的数字数量。现在,我的快速解决方案是在textFieldDidEndEditing中实现的。
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
}

对于shouldChangeCharactersIn:replacementString:,根据Jayesh Miruliya的Swift 3.0答案,在每四个字符组之间添加分隔符。

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

这段代码看起来非常吓人。你可以用 "while" 代替三个 if 语句,避免许多不必要的变量。 - user3206558
-1;正如您所指出的,这里的大部分代码只是从另一个用户的答案中复制并粘贴而来(正如我在那个答案中所指出的,它不起作用),其余部分也没有回答问题。 - Mark Amery

3

定义以下方法,并在UITextfield委托或任何需要的地方调用它

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

我不清楚你打算如何使用这个方法,而且它没有处理文本光标定位的功能。-1。 - Mark Amery

2

基于Mark Amery的Objective-C解决方案,以下是Swift 3解决方案:

  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
    }
    

我已经在这个答案中编辑了归属,我可以看出它是基于我的(它具有相同的变量和方法名称)。我对版权侵犯和抄袭持相当自由的态度,我想也许你认为在这里归属不是一个大问题,因为我的答案就在同一页上,但是在没有明确指出这是什么或链接到原始来源的情况下直接从一种语言转换到另一种语言的代码仍然似乎是错误的(在给予归属将是微不足道的情况下)。出于这个原因,我在这个答案上留下了-1。 - Mark Amery

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

2

Swift 5.1,Xcode 11

尝试了许多解决方案后,我遇到了一些问题,例如设置正确的光标位置和按需格式化。最终,在结合了两篇文章(https://dev59.com/E2ct5IYBdhLWcg3wc9Eg#38838740https://dev59.com/E2ct5IYBdhLWcg3wc9Eg#45297778)之后,我找到了一个解决方案。

原始答案翻译成中文为“最初的回答”。

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

1
以下是被接受的答案的快速复制。它基本上是一个包装类:
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
    }
}

1
-1;你没有在任何地方设置previousTextContent,因此它接收到了nil(或者,如果你将其设置为String而不是String?,则接收到一些随机的垃圾字节)。这意味着如果你超出19个字符,整个文本字段就会被清空(或者如果你运气不好,应用程序可能会直接崩溃 - 但到目前为止我只看到了清空)。 - Mark Amery
2
@MarkAmery,我很欣赏你在这篇文章中的辛勤工作和对每个解决方案的严谨分析 :) 正如提到的那样,这只是一个快速解决方案,可能无法考虑到一些边缘情况。同时,这也鼓励人们不仅仅是复制粘贴在Stack上找到的解决方案,而是理解并提出改进的答案。祝你也有愉快的一天 (; - Fawkes

1

您可以使用我的简单库:DECardNumberFormatter

示例:

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

输出:

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

1

以下是基于Mark Amery的 Kotlin 答案:

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
}

然后在编辑文本上添加一个文本更改监听器:
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?) {}
    })

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