Search code examples
iosswiftswiftuiuitextfielddelegate

SwiftUI TextField - Binding values with custom delegate


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.


Solution

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