Skip to content

2. Text Field Listener

Egor Taflanidi edited this page Mar 16, 2020 · 6 revisions

While the Mask is the cornerstone class of this library, text field listeners are the objects you'll be dealing with most of the time.

These listeners encapsulate the internal logic, giving you a façade of methods, properties, and callbacks to be configured.

Text field listeners implement text field's event handling, cursor movement logic, autocompletion control and automatic mask switching. They also interface underlying mask metrics and compiler notations.

Input Mask library provides three implementations of a text field listener.

MaskedTextFieldDelegate  :  UITextFieldDelegate
MaskedTextViewDelegate   :  UITextViewDelegate
MaskedTextInputListener  :  UITextFieldDelegate, UITextViewDelegate   @available(iOS 11, *)

All three are essentially the same class with almost the same logic, yet they had been divided because of the iOS SDK limitations, see below.

That said, we are going to review the MaskedTextFieldDelegate, others behave the same.

Receiving events

MaskedTextFieldDelegate provides two ways for you to receive text editing events. It has its own MaskedTextFieldDelegateListener protocol, which is a UITextFieldDelegate:

@objc public protocol MaskedTextFieldDelegateListener: UITextFieldDelegate {
    @objc optional func textField(
        _ textField: UITextField,
        didFillMandatoryCharacters complete: Bool,
        didExtractValue value: String
    )
}

MaskedTextFieldDelegate forwards each received UITextFieldDelegate call to its MaskedTextFieldDelegateListener, yet the textField(textField:shouldChangeCharactersIn:replacementString:) method is going to ignore your returned value: returning false is essential for the correct library functioning.

weak var listener: MaskedTextFieldDelegateListener?
@IBOutlet var delegate: NSObject?

MaskedTextFieldDelegateListener is assigned through the listener property.
delegate property provides additional Interface Builder support, allowing to wire up the listener directly on canvas; delegate is essentially an IB outlet for the listener field.

This callback:

var onMaskedTextChangedCallback: ((_ textField: UITextField, _ value: String, _ complete: Bool) -> ())?

— is the second way to receive text editing events. Here,

  • textField is a text field instance, which created an event;
  • value is an extracted value;
  • complete flag shows if an extracted value is complete.

Initialisation

MaskedTextFieldDelegate is an NSObject, meaning that it can be dropped on a canvas as an Interface Builder object, initialised with its convenience init method:

convenience init()

Using this method, a newly created MaskedTextFieldDelegate will have all its properties set to default.
Otherwise, you may programmatically create a MaskedTextFieldDelegate instance, providing all the settings through the designated initialiser:

init(
    primaryFormat:       String   = "",
    autocomplete:        Bool     = true,
    autocompleteOnFocus: Bool     = true,
    autoskip:            Bool     = false,
    rightToLeft:         Bool     = false,
    affineFormats:       [String] = [],
    affinityCalculationStrategy: AffinityCalculationStrategy = .wholeString,
    customNotations:             [Notation]                  = [],
    onMaskedTextChangedCallback: ((_ textInput: UITextInput, _ value: String, _ complete: Bool) -> ())? = nil
)
  • primaryFormat — the main mask pattern;
  • autocompleteautocompletion option;
  • autocompleteOnFocus — in case your mask pattern contains a fixed prefix (like a country code in phone numbers), this prefix will be automatically inserted on focus;
  • autoskipautomatic character skipping option;
  • rightToLeft — enable right-to-left text formatting;
  • affineFormats — a list of affine formats;
  • affinityCalculationStrategy — see affine formats;
  • customNotations — a list of compiler notations;
  • onMaskedTextChangedCallback — this closure is called on every text change, see above.

Properties

Some of the MaskedTextFieldDelegate properties are @IBInspectable, meaning that you may configure them through the Interface Builder inspector.

@IBInspectable open var primaryMaskFormat:   String
@IBInspectable open var autocomplete:        Bool
@IBInspectable open var autocompleteOnFocus: Bool
@IBInspectable open var autoskip:            Bool
@IBInspectable open var rightToLeft:         Bool
  • primaryMaskFormat — the main mask pattern;
  • autocompleteautocompletion option;
  • autocompleteOnFocus — in case your mask pattern contains a fixed prefix (like a country code in phone numbers), in will be automatically inserted on focus;
  • autoskipautomatic character skipping option;
  • rightToLeft — enable right-to-left text formatting.
var affineFormats:               [String]
var affinityCalculationStrategy: AffinityCalculationStrategy
var customNotations:             [Notation]

Readonly properties and metrics

var primaryMask: Mask { get }

var placeholder:           String { get }
var acceptableTextLength:  Int    { get }
var totalTextLength:       Int    { get }
var acceptableValueLength: Int    { get }
var totalValueLength:      Int    { get }

Here, primaryMask is the main Mask object used to format the input.
Other readonly properties represent primary mask's properties and metrics.

Atomic cursor movement, an ugly workaround property

@IBInspectable open var atomicCursorMovement: Bool = true

There is an actual iOS bug, that is evident in the Contacts.app while you edit a phone number field. Try copy-pasting a 1234567890 string into the phone field and notice the actual cursor position.

Shortly after new text is being pasted from the clipboard, UITextField receives a new value for its selectedTextRange property from the system (this affects cursor position). This new range is not consistent with the formatted text and calculated cursor position most of the time, yet it's being assigned just after set cursorPosition call.

To ensure correct cursor position is set, it is assigned asynchronously (presumably after a vanishingly small delay), if cursor movement is set to be non-atomic.

Default value is false.

Methods

func put(text: String, into: UITextField, autocomplete: Bool? = nil) -> Mask.Result

This method is designed to programmatically insert raw text into the UITextField, simultaneously applying the format.

UITextInput protocol and code duplication

So, what is this all about having three different text field listeners, that almost duplicate each other's code?

Well, first of all, UITextFieldDelegate and UITextViewDelegate are two different protocols, hence the need of a separate MaskedTextViewDelegate.

UITextFieldDelegate and UITextViewDelegate callbacks are almost the same, except for the «shouldChangeTextInRange». Both callbacks have the same type signature, yet for the empty UITextView on Backspace hit this callback is called, but for the empty UITextField it is not.

With the autocompletion enabled this leads to the automatic mask prefix insertion, like during the autocompleteOnFocus, but on every each Backspace hit.

Next, there's a handy UITextInput protocol, which unites UITextField and UITextView classes, making it possible to generalise related logic.

Except there's no way to read or replace the whole text contained inside the UITextInput. The only methods are

func text(in range: UITextRange) -> String?
func replace(_ range: UITextRange, withText text: String)

— both require an UITextRange argument, which shouldn't be a problem since the UITextInput has this:

func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange?

This method allows to get the "whole" text range by calling

let field: UITextInput = ...
let range: UITextRange = field.textRange(from: field.beginningOfDocument, to: field.endOfDocument)

Okay, puzzle solved? NOT SO FAST!

Prior to iOS 11, there's a bug, when non-focused text fields (and text views) have nil inside their beginningOfDocument and endOfDocument properties. Thus, you won't be able to put formatted text inside the UITextInput while it's not focused.

For both UITextField and UITextView there's a working field.text property, which does not require any UITextRange arguments to be operational. Unfortunately, UITextInput instances do not have this sophisticated ability.

Thus, MaskedTextInputListener is only available on iOS 11 and higher, and I won't get rid of this code duplication until iOS 10 support is dropped.