SwiftUI custom TextField. Part 5: Input mask


Greetings, traveler!

We are concluding our series of articles about the text field. Today, I will guide you on effectively adding an input mask to a text field. This is particularly useful when creating an input field for a phone number, card number, or any special code. Crafting a good mask can be complex, but we have a solution. We will be using a ready-made option from RedMadRobot. In fact, they have already provided a masked text field option for SwiftUI. However, we will create our custom solution to adapt and customize it to our needs. Let’s get started.

Input Mask

First, let’s install the library via Swift Package Manager. Once that’s done, we can import it into the file where we placed our text field.

dependencies: [
      .package(url: "https://github.com/RedMadRobot/input-mask-ios.git", .upToNextMajor(from: "7.0.0"))
]

To create a mask for the input in the text field, you must define an inputListener property with the optional MaskedTextInputListener type. This property should be present in both the text field and the coordinator. In the makeUIView function, we need to ensure that the value of the InputListener property is not nil. If it is not nil, we assign the coordinator as the delegate of the InputListener text field. If it is nil, then the coordinator itself becomes the delegate. This ensures the proper functioning of the mask in the text field.

struct CustomTextField: UIViewRepresentable {
    
    var inputListener: MaskedTextInputListener?
    
}
func makeUIView(context: Context) -> UITextField {
    let textField = UITextField()
    
    if inputListener == nil {
        textField.delegate = context.coordinator
    } else {
        textField.delegate = context.coordinator.inputListener
    }
    
    return textField
}

In the coordinator initializer, we ensure we have received the InputListener value. If we have received it, we set the textFieldDelegate of this class to be the coordinator itself. There is no need to perform further actions inside the coordinator, as the inputListener will handle all the work.

final class Coordinator: NSObject, UITextFieldDelegate {
    
    var inputListener: MaskedTextInputListener?
    var inputAccessoryViewFactory: InputAccessoryViewFactoryProtocol?
    
    @Binding private var text: String
    @Binding private var isFirstResponder: Bool
    
    private var didBeginEditing: () -> Void
    private var didChange: () -> Void
    private var didEndEditing: () -> Void
    private var shouldReturn: () -> Void
    private var shouldClear: () -> Void
    private var onValidate: (ValidationResult) -> Void
    
    private var characterLimit: Int? = nil
    private var isValidateAfterFinishEditing: Bool
    private var isValidateWhileEditing: Bool
    private var validations: [ValidatorProtocol]
    
    private var wasEdited = false
    
    init(
        text: Binding<String>,
        isFirstResponder: Binding<Bool>,
        characterLimit: Int?,
        isValidateAfterFinishEditing: Bool,
        isValidateWhileEditing: Bool,
        validations: [ValidatorProtocol],
        inputListener: MaskedTextInputListener?,
        didBeginEditing: @escaping () -> Void,
        didChange: @escaping () -> Void,
        didEndEditing: @escaping () -> Void,
        shouldReturn: @escaping () -> Void,
        shouldClear: @escaping () -> Void,
        onValidate: @escaping (ValidationResult) -> Void
    ) {
        
        self._text = text
        self._isFirstResponder = isFirstResponder
        self.characterLimit = characterLimit
        self.isValidateAfterFinishEditing = isValidateAfterFinishEditing
        self.isValidateWhileEditing = isValidateWhileEditing
        self.validations = validations
        self.didBeginEditing = didBeginEditing
        self.didChange = didChange
        self.didEndEditing = didEndEditing
        self.shouldReturn = shouldReturn
        self.shouldClear = shouldClear
        self.onValidate = onValidate
        
        super.init()
        
        guard let inputListener else { return }
        
        self.inputListener = inputListener
        self.inputListener?.textFieldDelegate = self
    }
 
 ...
}

Programmatic text insertion

Now, all the text that the user will enter will be masked. However, transmitting the text differently will leave it unmasked. And now we will fix it.

Whenever a change is made to the text field, the updateUIView function gets called. This is where we will place our code. To get started, we can create a boolean property named shouldInsertMaskedText, indicating whether we want this behavior. Then, in the updateUIView function, we will check if this property is set to true. If it is true and the isFirstResponder property is false, we will forcibly replace the text inside the field with a masked one.

func updateUIView(_ uiView: UITextField, context: Context) {
    if shouldInsertMaskedText && !isFirstResponder {
        inputListener?.put(text: uiView.text ?? "", into: uiView)
    }
    
	...
}

Customization

Now, let’s create tools to customize all of this. As usual, we will use the extension for our text field. After that, we can use it.

extension CustomTextField {
    
    func masks(_ masks: String...) -> CustomTextField {
        var view = self
        view.inputListener = .init(affineFormats: masks)
        
        return view
    }
    
    func shouldInsertMaskedText(_ shouldInsertMaskedText: Bool) -> iTextField {
        var view = self
        view.shouldInsertMaskedText = shouldInsertMaskedText
        
        return view
    }
    
}

struct ContentView: View {

    @State private var text = ""
    @State private var isFirstResponder = false
    
    var body: some View {
        
        CustomTextField(text: $text, isFirstResponder: $isFirstResponder)
            .shouldInsertMaskedText(true)
            .masks("{+1} ([000]) [000]-[00]-[00]")
    }
    
}

Done!

Conclusion

We have completed our series of articles on creating a custom SwiftUI TextField. We started with the basics, creating a text field based on UITextField in SwiftUI View. We learned how to handle text field events and customize their behavior. Then, we learned how to customize the text field properties to our needs. We also created a universal mechanism for adding inputAccessoryView to our text field. After that, we learned how to validate the field’s contents to provide up-to-date feedback to the user improving the user experience. Finally, we implemented a mask for our text field. We have done an excellent job, I can say.

By the way, the complete code is available on the GitHub.

See you soon!