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!