SwiftUI custom TextField. Part 4: Input validation


Greetings, traveler!

As we continue our journey in creating a custom text field in SwiftUI, we encounter a scenario where we must set specific input rules for our text field. However, our focus extends beyond the technicalities. We’re also considering the user’s experience. If these rules are violated, we want to notify the user about them without prohibiting them from entering any characters or quantity. To achieve this, we need to check the field’s contents at the input or when the user has finished typing and trigger an event that notifies external views of the validation result. This is where the validator object comes into play. We’ll leave the responsibility of content validation to this separate object. First, let’s create a protocol for this case. The protocol will have one function that returns the validation result. We will design this result as a structure with two properties: a property that returns the validation result (okay or not okay) and a property that can contain a message to the user.

protocol ValidatorProtocol {
    func validate<T>(_ value: T) -> ValidationResult
}

struct ValidationResult {
    var isValid: Bool
    var message: String?
    
    public init(isValid: Bool, message: String? = nil) {
        self.isValid = isValid
        self.message = message
    }
}

As we progress, we realize the need to share the responsibility of different types of validation between different entities. To address this, we’ll teach our text field to work with an array of validators. This approach not only enhances the flexibility of our solution but also makes it more scalable. I suggest creating an array extension for more convenient validation using an array of validators.

extension Array: ValidatorProtocol where Element == ValidatorProtocol {
    func validate<T>(_ value: T) -> ValidationResult {
        for validator in self {
            let result = validator.validate(value)
            guard result.isValid else {
                return result
            }
        }
        return .init(isValid: true, message: nil)
    }
}

extension Array where Element: ValidatorProtocol {
    func validate<T>(_ value: T) -> ValidationResult {
        (self as [ValidatorProtocol]).validate(value)
    }
}

Now, we can apply this protocol to its specific implementation. Don’t worry, we’re taking it one step at a time. Create a simple validator that will allow you to notify the user if the text field that he/she fills in is left empty.

final class NonEmptyValidator: ValidatorProtocol {
    func validate<T>(_ value: T) -> ValidationResult {
        guard let string = value as? String else {
            return .init(isValid: false, message: "The field must not be empty")
        }
        
        guard string.isEmpty else {
            return .init(isValid: true)
        }
        
        return .init(isValid: false, message: "The field must not be empty")
    }
}

Let’s leave our validator for now and return to the text field. We need to create a closure to pass the validation result. We must also remember to make a closure in the coordinator, similar to last time. Also, we need to create a property with an array of validators.

To determine when we will validate a text field, we can create two additional properties: isValidateAfterFinishEditing and isValidateWhileEditing.

struct CustomTextField: UIViewRepresentable {
    
    var didBeginEditing: () -> Void = {}
    var didChange: () -> Void = {}
    var didEndEditing: () -> Void = {}
    var shouldReturn: () -> Void = {}
    
    var onValidate: (ValidationResult) -> Void = { _ in }
    var validations: [ValidatorProtocol] = []
    
    var isValidateAfterFinishEditing = false
    var isValidateWhileEditing = false
    
}
final class Coordinator: NSObject, UITextFieldDelegate {
    
    @Binding var text: String
    var inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?
    
    @Binding private var isFirstResponder: Bool
    
    private var didBeginEditing: () -> Void
    private var didChange: () -> Void
    private var didEndEditing: () -> Void
    private var shouldReturn: () -> Void
    
    private var onValidate: (ValidationResult) -> Void
    private var validations: [ValidatorProtocol]
    
    private var isValidateAfterFinishEditing: Bool
    private var isValidateWhileEditing: Bool
    
    init(
        text: Binding<String>,
        isFirstResponder: Binding<Bool>,
        validations: [ValidatorProtocol],
        inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?,
        didBeginEditing: @escaping () -> Void,
        didChange: @escaping () -> Void,
        didEndEditing: @escaping () -> Void,
        shouldReturn: @escaping () -> Void,
        onValidate: @escaping (ValidationResult) -> Void
    ) {
        
        self._text = text
        self._isFirstResponder = isFirstResponder
        self.validations = validations
        self.inputAccessoryViewFactory = inputAccessoryViewFactory
        self.didBeginEditing = didBeginEditing
        self.didChange = didChange
        self.didEndEditing = didEndEditing
        self.shouldReturn = shouldReturn
        self.onValidate = onValidate
    }
...

}

In the UITextField delegate methods, we will use this data to validate the content.

final class Coordinator: NSObject, UITextFieldDelegate {
    
    @Binding var text: String
    var inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?
    
    @Binding private var isFirstResponder: Bool
    
    private var didBeginEditing: () -> Void
    private var didChange: () -> Void
    private var didEndEditing: () -> Void
    private var shouldReturn: () -> Void
    
    private var onValidate: (ValidationResult) -> Void
    private var validations: [ValidatorProtocol]
    
    private var isValidateAfterFinishEditing: Bool
    private var isValidateWhileEditing: Bool
    
    private var wasEdited = false
    
    init(
        text: Binding<String>,
        isFirstResponder: Binding<Bool>,
        validations: [ValidatorProtocol],
        inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?,
        didBeginEditing: @escaping () -> Void,
        didChange: @escaping () -> Void,
        didEndEditing: @escaping () -> Void,
        shouldReturn: @escaping () -> Void,
        onValidate: @escaping (ValidationResult) -> Void
    ) {
        
        self._text = text
        self._isFirstResponder = isFirstResponder
        self.validations = validations
        self.inputAccessoryViewFactory = inputAccessoryViewFactory
        self.didBeginEditing = didBeginEditing
        self.didChange = didChange
        self.didEndEditing = didEndEditing
        self.shouldReturn = shouldReturn
        self.onValidate = onValidate
    }
    
    func textFieldDidBeginEditing(_ textField: UITextField) {
        DispatchQueue.main.async { [self] in
            text = textField.text ?? ""
            
            if !isFirstResponder {
                isFirstResponder = true
            }
            
            didBeginEditing()
        }
    }
    
    @objc func textFieldDidChange(_ textField: UITextField) {
        text = textField.text ?? ""
        didChange()
        
        if isValidateWhileEditing, wasEdited {
            validate(textField.text ?? "")
        }
        
        if !wasEdited {
            wasEdited = !text.isEmpty
        }
    }
    
    func textFieldDidEndEditing(
        _ textField: UITextField,
        reason: UITextField.DidEndEditingReason
    ) {
        
        DispatchQueue.main.async { [self] in
            if isFirstResponder {
                isFirstResponder = false
            }
            
            didEndEditing()
            
            if isValidateAfterFinishEditing {
                validate(textField.text ?? "")
            }
        }
    }
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        isFirstResponder = false
        shouldReturn()
        return false
    }
    
    private func validate(_ text: String) {
        let result = validations.validate(text)
        DispatchQueue.main.async {
            self.onValidate(result)
        }
    }
    
}

Configuration

Now, let’s create a function in the text field extension to configure this closure.

extension CustomTextField {
    
    func validations(
        _ validations: [ValidatorProtocol],
        isValidateAfterFinishEditing: Bool,
        isValidateWhileEditing: Bool,
        onValidate: @escaping ((ValidationResult) -> Void)
    ) -> CustomTextField {
        
        var view = self
        view.isValidateAfterFinishEditing = isValidateAfterFinishEditing
        view.isValidateWhileEditing = isValidateWhileEditing
        view.validations = validations
        view.onValidate = onValidate
        
        return view
    }
    
}

Now we can receive events from the outside and react to them as we please. 

struct ContentView: View {

    @State private var text = ""
    @State private var isTextFieldDisabled = false
    @State private var isFirstResponder = false
    
    @FocusState private var isFocused: Bool
    
    var body: some View {
        
        CustomTextField(text: $text, isFirstResponder: $isFirstResponder)
            .validations(
                [NonEmptyValidator()],
                isValidateAfterFinishEditing: true,
                isValidateWhileEditing: true
            ) { result in
                
                print(result.isValid)
                print(result.message)
            }
    }
    
}

Conclusion

We have successfully implemented a convenient tool that validates text fields with a scalable and user-friendly interface, delivering a better user experience.

Are you looking for the complete code? It is on the GitHub.

Thank you for reading this article, and see you in the next one!