SwiftUI TextField - Binding values with custom delegate

149 Views Asked by At

I wanted to preform live formatting to a value entered into a SwiftUI TextField, so I used introspect to add a custom delegate, like in UIKit. This caused the .onChange to stop working. If I comment out the delegate, the .onChange block works. How do I get the binding to work with the custom delegate?

TextFieldView:

import Foundation
import SwiftUI


struct TextFieldView: View {
    
    @State private var inputString: String = ""
    
    var customDelegate = CustomTextFieldDelegate()
    
    var body: some View {
        VStack {
            TextField("", text: $inputString)
                .addTextFieldDelegate(delegate: customDelegate)
        }
        .onChange(of: inputString) { _ in
            print("inputString: \(inputString)")
        }
    }
}

Introspect:

extension View {
    func addTextFieldDelegate(delegate: UITextFieldDelegate) -> some View {
        introspect(.textField, on: .iOS(.v15...)) { textfield in
            textfield.delegate = delegate
        }
    }
}

Custom Delegate:

class CustomTextFieldDelegate: NSObject, UITextFieldDelegate {
    
    override init() { }
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        textField.resignFirstResponder()
            return true
    }
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        
        guard let text = textField.text, let textRange = Range(range, in: text) else {
            return false
        }
        
        var updatedText = text.replacingCharacters(in: textRange, with: string)
        updatedText.removeAll(where: {$0 == ":"})
        
        let finalLength = updatedText.count + updatedText.count/2 - 1
        
        if finalLength > 8 {
            return false
        }
        for i in stride(from: 2, to: finalLength, by: 3) {
            let index = updatedText.index(updatedText.startIndex, offsetBy: i)
            updatedText.insert(":", at: index)
        }
        textField.text = updatedText
        
        return false
    }
    

}

Edit 01:

What I want to achieve is to add a ":" ever two characters as the input is being typed into the TextField. I would prefer not to use introspect, but not sure how to modify the variable inputString without triggering the onChange handler again, thus leading to an infinite loop.

1

There are 1 best solutions below

3
Paulw11 On BEST ANSWER

You should not rely on the fact that SwiftUI is built on UIKit - This could well change in the future.

You don't need to use the UITextFieldDelegate - You can perform your processing in the onChange handler.

We can check that the old and new values are the same to prevent an onChange loop.

For example:

struct ContentView: View {
    
    @State var oldValue = ""
    @State var inputString = ""
    
    var body: some View {
        VStack {
            TextField("XX:XX:XX:XX", text:$inputString)
        }
        .onChange(of: inputString) { newValue in
            self.formatTextField(newValue)
        }
    }
    
    func formatTextField(_ newValue: String) {
        guard self.oldValue != self.code else {
            return
        }
        var finalValue = newValue
        if newValue.count > self.oldValue.count {
            finalValue = self.processInsert(newValue: newValue, oldValue: self.oldValue)
        } else {
            finalValue = self.processDelete( newValue: newValue, oldValue: self.oldValue)
        }
        self.oldValue = finalValue
        self.inputString = finalValue
    }
    
    func processInsert(newValue: String, oldValue:String) -> String {
        guard newValue.count < 12 else {
            return oldValue
        }
        var characters = newValue
        characters.removeAll { $0 == ":"}
        var finalValue = ""
        
        for i in 0..<characters.count {
            finalValue.append(characters[characters.index(characters.startIndex,offsetBy: i)])
            if i < 7 && i > 0 && (i-1) % 2 == 0 {
                finalValue.append(":")
            }
        }
        
        return finalValue
        
    }
    
    func processDelete(newValue: String, oldValue: String) -> String {
        var finalValue = newValue
        var input = finalValue
        input.removeAll { $0 == ":"}
        let length = input.count
        if length > 0 && length % 2 == 0 {
            finalValue = String(finalValue.prefix(finalValue.count-1))
        }
        return finalValue
    }
}