UITextField
credit card input
iOS development
Swift programming
user interface design

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

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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

swift
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:

swift
1func getRawCardNumber() -> String {
2    return cardField.text?.filter { $0.isNumber } ?? ""
3}
4
5// Usage
6let rawNumber = getRawCardNumber()
7print(rawNumber) // "1234567890123456"

Supporting Different Card Formats

Not all cards use 4-4-4-4 grouping. Amex uses 4-6-5, Diners Club uses 4-6-4:

swift
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+)

swift
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

swift
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

Course illustration
Course illustration

All Rights Reserved.