Introduction
Formatting a UITextField for credit card input requires intercepting text changes as the user types and inserting spaces every 4 digits. The standard approach is to implement UITextFieldDelegate's textField(_:shouldChangeCharactersIn:replacementString:) method, strip non-digit characters, then reformat with spaces before updating the field. This gives users the familiar xxxx xxxx xxxx xxxx pattern while keeping the underlying data clean.
Basic Implementation
1import UIKit
2
3class CreditCardViewController: UIViewController, UITextFieldDelegate {
4
5 @IBOutlet weak var cardField: UITextField!
6
7 override func viewDidLoad() {
8 super.viewDidLoad()
9 cardField.delegate = self
10 cardField.keyboardType = .numberPad
11 cardField.placeholder = "1234 5678 9012 3456"
12 }
13
14 func textField(_ textField: UITextField,
15 shouldChangeCharactersIn range: NSRange,
16 replacementString string: String) -> Bool {
17 // Get current text and apply the replacement
18 let currentText = textField.text ?? ""
19 guard let textRange = Range(range, in: currentText) else { return false }
20 let updatedText = currentText.replacingCharacters(in: textRange, with: string)
21
22 // Strip all non-digit characters
23 let digitsOnly = updatedText.filter { $0.isNumber }
24
25 // Limit to 16 digits
26 guard digitsOnly.count <= 16 else { return false }
27
28 // Format with spaces every 4 digits
29 let formatted = formatCardNumber(digitsOnly)
30 textField.text = formatted
31
32 // Move cursor to end
33 let endPosition = textField.endOfDocument
34 textField.selectedTextRange = textField.textRange(from: endPosition, to: endPosition)
35
36 return false // We set the text manually
37 }
38
39 func formatCardNumber(_ digits: String) -> String {
40 var result = ""
41 for (index, char) in digits.enumerated() {
42 if index > 0 && index % 4 == 0 {
43 result += " "
44 }
45 result.append(char)
46 }
47 return result
48 }
49}
Getting the Raw Card Number
Always store and transmit the digits without formatting:
1func getRawCardNumber() -> String {
2 return cardField.text?.filter { $0.isNumber } ?? ""
3}
4
5// Usage
6let rawNumber = getRawCardNumber()
7print(rawNumber) // "1234567890123456"
Not all cards use 4-4-4-4 grouping. Amex uses 4-6-5, Diners Club uses 4-6-4:
1enum CardType {
2 case visa, mastercard, amex, discover, unknown
3
4 var grouping: [Int] {
5 switch self {
6 case .amex: return [4, 6, 5] // 15 digits
7 case .visa, .mastercard, .discover: return [4, 4, 4, 4] // 16 digits
8 case .unknown: return [4, 4, 4, 4]
9 }
10 }
11
12 var maxDigits: Int {
13 return grouping.reduce(0, +)
14 }
15
16 static func detect(from digits: String) -> CardType {
17 if digits.hasPrefix("34") || digits.hasPrefix("37") { return .amex }
18 if digits.hasPrefix("4") { return .visa }
19 if digits.hasPrefix("5") { return .mastercard }
20 if digits.hasPrefix("6011") { return .discover }
21 return .unknown
22 }
23}
24
25func formatCardNumber(_ digits: String, type: CardType) -> String {
26 var result = ""
27 var index = 0
28 for groupSize in type.grouping {
29 if index > 0 { result += " " }
30 let end = min(index + groupSize, digits.count)
31 if index < end {
32 let start = digits.index(digits.startIndex, offsetBy: index)
33 let endIdx = digits.index(digits.startIndex, offsetBy: end)
34 result += String(digits[start..<endIdx])
35 }
36 index += groupSize
37 }
38 return result
39}
Using Combine (iOS 13+)
1import Combine
2
3class CardFieldModel: ObservableObject {
4 @Published var cardText = ""
5 private var cancellables = Set<AnyCancellable>()
6
7 init() {
8 $cardText
9 .map { text -> String in
10 let digits = text.filter { $0.isNumber }
11 let limited = String(digits.prefix(16))
12 var result = ""
13 for (i, char) in limited.enumerated() {
14 if i > 0 && i % 4 == 0 { result += " " }
15 result.append(char)
16 }
17 return result
18 }
19 .assign(to: &$cardText)
20 }
21}
Validation
1func isValidCardNumber(_ digits: String) -> Bool {
2 // Luhn algorithm
3 guard digits.count >= 13 && digits.count <= 19 else { return false }
4
5 var sum = 0
6 let reversed = digits.reversed().map { Int(String($0))! }
7
8 for (index, digit) in reversed.enumerated() {
9 if index % 2 == 1 {
10 let doubled = digit * 2
11 sum += doubled > 9 ? doubled - 9 : doubled
12 } else {
13 sum += digit
14 }
15 }
16
17 return sum % 10 == 0
18}
19
20// Usage
21let raw = getRawCardNumber()
22if isValidCardNumber(raw) {
23 print("Valid card number")
24} else {
25 print("Invalid card number")
26}
Common Pitfalls
Returning true from shouldChangeCharactersIn: If you return true after setting textField.text, the system applies the replacement again on top of your formatted text, producing double characters. Always return false when you set the text manually.
Not handling backspace correctly: When the user deletes a space, the cursor can get stuck. Strip all non-digits before reformatting so that deleting a space also removes the adjacent digit cleanly.
Hardcoding 16-digit limit for all cards: Amex cards have 15 digits, Diners Club has 14. Detect the card type from the prefix and apply the correct max length and grouping pattern.
Forgetting to set keyboardType: Without .numberPad, users can type letters that pass through the digit filter as empty strings, causing confusing cursor behavior. Always set the keyboard type.
Storing formatted text: Never store or transmit the formatted string ("1234 5678 9012 3456") to your payment API. Always strip spaces before sending: text.filter { $0.isNumber }.
Summary
Implement UITextFieldDelegate to intercept text changes and reformat with spaces
Strip non-digit characters, limit length, then insert spaces every 4 digits
Return false from shouldChangeCharactersIn after setting the text manually
Detect card type from the prefix to support Amex (4-6-5) and other non-standard groupings
Validate card numbers with the Luhn algorithm before submission
Always store and transmit the raw digits, not the formatted display string