Arturiusz Pająk
Arturiusz Pająk

Reputation: 19

SwiftUI Formatting TextField for credit card input "xxxx-xxxx-xxxx-xxxx"

I want to write a custom TextField on SwiftUI to have an input with credit card format like this "xxxx-xxxx-xxxx-xxxx", I was looking at this answers https://stackoverflow.com/a/48252437, but I'm struggling with UIViewRepresentable, when I want to past the numbers into the original text, it doesn't seems to update it with the format.

The textField will show you 12345, but I need 1234 5

struct ContentView: View {
    
    @State var cardNumber: String = "12345" // <-- It won't be formatted until I type another one number
    
    var body: some View {
        VStack {
            CreditCardTextField(number: $cardNumber)
                .frame(height: 50)
                .border(.black)
        }
        .padding()
    }
}

And UIViewRepresentable where I transported the code from the link answer

struct CreditCardTextField: UIViewRepresentable {
    @Binding public var number: String
    
    public init(number: Binding<String>) {
        self._number = number
    }
    
    public func makeUIView(context: Context) -> UITextField {
        let view = UITextField()
        view.addTarget(context.coordinator, action: #selector(Coordinator.reformatAsCardNumber), for: .editingChanged)
        view.delegate = context.coordinator
        
        return view
    }
    
    public func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = number // <-- I believe in here I should update the code so that it will be formatted, but I can't get how to refactore the code
    }
    
    public func makeCoordinator() -> Coordinator {
        Coordinator($number)
    }
    
    // MARK: Coordinator
    public class Coordinator: NSObject, UITextFieldDelegate {
        @Binding var number: String
        private var previousTextFieldContent: String?
        private var previousSelection: UITextRange?
        
        init(_ number: Binding<String>) {
            self._number = number
        }
        
        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
            previousTextFieldContent = textField.text
            previousSelection = textField.selectedTextRange
            return true
        }
        
        @objc func reformatAsCardNumber(textField: UITextField, for event: UIControl.Event) {
            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 > 16 {
                textField.text = previousTextFieldContent
                textField.selectedTextRange = previousSelection
                return
            }
            
            let cardNumberWithSpaces = self.insertCreditCardSpaces(cardNumberWithoutSpaces, preserveCursorPosition: &targetCursorPosition)

            textField.text = cardNumberWithSpaces
            number = 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 {
            var stringWithAddedSpaces = ""
            let cursorPositionInSpacelessString = cursorPosition
            
            for i in 0..<string.count {
                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
        }
    }
}

Upvotes: 1

Views: 19396

Answers (2)

sergey mishunin
sergey mishunin

Reputation: 11

just implement Coordinator with parent parameter (your CreditCardTextField) and implement UITextFieldDelegate method textViewDidChange - make all manipulations with string there and apply result to parent.number

Upvotes: 0

malhal
malhal

Reputation: 30746

Essentially the problem is updateUIView is missing some logic, e.g. it needs to check if the textField already contains the formatted number and if it doesn't it needs to format it and set it. Another bug is you don't set the new binding on the coordinator. The binding value changes every time the representable is init and you only gave it the first version when you init the Coordinator.

Fyi binding is just a pair of get/set closures so it's unusual to pass a binding to the coordinator because it doesn't need the get (also its designed for updating SwiftUI Views not NSObjects). You would be better setting a didChange closure in updateUIView. Note sometimes we set it nil before we do our other logic of checking the UIView and setting the new data (because we don't want to get in an update loop).

If I was going to redesign this I would first make a formatting the text a static method that takes a string and returns a string, rather than have that code in the target action. That way it can used from the 2 places it is needed.

Upvotes: -2

Related Questions