NeilsUltimateLab

OneTimeCodeField *⃣

Let's say we want to develop a OneTimeCode Or OneTimePassword field that will look something like this.

OTCField

And we want to have auto-fill feature (iOS 12+).

The standard UIControls

To create this type of UI we can use multiple UITextFields that each will become the firstResponder at every keystroke, because each field will have single character.

UITextField will render its default cursor, so we need to patch the tintColor property of the textFields.

Text-position in the text-field is not keeping the text in center at the time of editing.

For delete keystroke we need to identify the delete action (in code) to clear the current textField and make previous field the firstResponder.

So we need to find some solution that does not depend on the standard UITextField, but work exactly as single textField (ie. Interact with keyboard inputs).

So Investigating the UITextField class itself, we learned that it is a UIControl and conforms to UITextInput protocol which further extends UIKeyInput protocol.

There is also a good example called creating custom text input using UIKeyInput from Hacking With Swift's Swift Knowledge Base .

UIKeyInput

A set of methods a subclass of UIResponder uses to implement simple text entry.

Responders that implement the UIKeyInput protocol will be driven by the system-provided keyboard, which will be made available whenever a conforming responder becomes first responder.

This means when we tap on our custom field view and make it firstResponder, then system's keyboard will be presented automatically. 🤩

public protocol UIKeyInput : UITextInputTraits {
    var hasText: Bool { get }
    func insertText(_ text: String)
    func deleteBackward()
}

This protocol is also having insertText(_:) and deleteBackward() method requirements for interacting with system keystroke events. So our delete keystroke detection hack is solved too. 🥳

Our Own Text Input Control

// 1
class OneTimeCodeField: UIControl {
    
    // 2
    enum FieldState {
        case empty
        case filled
        case responding
    }

    // 3
    var digit: Int = 6
    var spacing: CGFloat = 12
    var onCompletion: ((String)->Void)?
    
    // 4
    var keyboardType: UIKeyboardType = .numberPad
    var textContentType: UITextContentType = .oneTimeCode
    
}
  1. We are declaring our OneTimeCodeField to be as UIControl just like UITextField.
  2. Then we declare some FieldState for rendering text states at particular location.
  3. We define digit to allow different passcode length and other properties for decoration and callback.
  4. Then we declare the keyboardType and textContentType to tell UIKit to open specific keyboard for our case.
class OneTimeCodeField: UIControl {
    ...
    
    // 5
    private var labels: [UILabel] = []
    private var layers: [CAShapeLayer] = []
    
    // 6
    private var currentIndex: Int = 0
    
    // 7
    private var yPosition: CGFloat {
        return self.bounds.height - 2
    }
    
    private var individualWidth: CGFloat {
        return (self.bounds.width - (CGFloat(digit - 1) * spacing)) / CGFloat(digit)
    }

}
  1. We will render our text using UILabels here and some CALayers for the bottom place marker.
  2. We can have one variable currentIndex to track the current cursor position.
  3. Some computed properties for sizing and positioning.
class OneTimeCodeField: UIControl {
    ...
    
    // 8
    private lazy var tapGesture: UITapGestureRecognizer = {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapAction))
        return tapGesture
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    // 9
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        
        for index in 0..<digit {
            let placeLayer = shapeLayer(at: index)
            self.layer.addSublayer(placeLayer)
            self.layers.append(placeLayer)
        }
    }
    
    private func setup() {
        self.addGestureRecognizer(tapGesture)

        for _ in 0..<digit {
            let label = self.label()
            stackView.addArrangedSubview(label)
            labels.append(label)
        }
        
        self.addSubview(stackView)
        stackView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
        stackView.leftAnchor.constraint(equalTo: self.leftAnchor).isActive = true
        stackView.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
        stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
    }
}
  1. We need some way to make our field first responder. So we initialize a tap gesture and add to ourself.
  2. Setting up the place-marker layers in draw(_:) and labels using stackView to render texts.
// 10
@objc private func tapAction(_ sender: UITapGestureRecognizer) {
    updateState(.responding, at: currentIndex)
    self.becomeFirstResponder()
}
  1. When user taps our field we make this as a firstResponder so UIKit can present the keyboard.

We now implement the UIKeyInput protocol requirements.

extension OneTimeCodeField: UIKeyInput {
    
    // 11
    private var textCount: Int {
        let count = labels.reduce(0) { (result, label) -> Int in
            return result + (label.text?.count ?? 0)
        }
        return count
    }
    
    // 12
    var hasText: Bool {
        textCount > 0
    }
    
    // 13
    func insertText(_ text: String) {
        guard let label = labels[safe: currentIndex] else { return }
        label.text = text
        updateState(.filled, at: currentIndex)
        if currentIndex >= (digit - 1) {
            self.onCompletion?(text)
            resignFirstResponder()
            return
        }
        currentIndex += 1
        updateState(.responding, at: currentIndex)
    }
    
    // 14
    func deleteBackward() {
        let label = labels[safe: currentIndex]
        label?.text = nil
        updateState(.empty, at: currentIndex)
        if currentIndex <= 0 {
            currentIndex = 0
            updateState(.responding, at: currentIndex)
            return
        }
        currentIndex -= 1
        updateState(.responding, at: currentIndex)
    }

}
  1. textCount is the computed property to return the all the characters count we have at the moment.
  2. First requirement of protocol. We simply return compare with the textCount property we created above.
  3. insertText(_:) method will insert the new text at the currentIndex's label and update cursor position.
  4. deleteBackward() method will be called when delete keystroke occurred. so we need to update the text and update the cursor position.

We have a safe subscript for array like this.

extension Array {
    subscript(safe index: Array.Index) -> Array.Element? {
        if index < 0 || index >= self.count { return nil }
        return self[index]
    }
}

Here we have it.

When user taps on it, it become first-responder and keyboard appears. and as the user types it updates the labels and cursor position.

But, this will not auto-fill the code.

To have auto-fill we need to extend our class for UITextInput protocol.

This enable features such as autocorrection and multistage text input in documents.

This protocol having a huge requirements for handling all aspect of text-editing. But in our case we just want to have text-entry and deletion. So we can provide empty implementation for the auto-fill feature.

extension OneTimeCodeField: UITextInput {
    func replace(_ range: UITextRange, withText text: String) {}

    var tokenizer: UITextInputTokenizer { UITextInputStringTokenizer() }

    var selectedTextRange: UITextRange? {
        get { nil }
        set(selectedTextRange) {}
    }

    var markedTextRange: UITextRange? { nil }

    var markedTextStyle: [NSAttributedString.Key : Any]? {
        get { nil }
        set(markedTextStyle) {}
    }
    
    ...
}

Full source code can be found at GitHub-gist.

Tagged with: