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

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
}
- We are declaring our OneTimeCodeField to be as
UIControljust like UITextField. - Then we declare some
FieldStatefor rendering text states at particular location. - We define
digitto allow different passcode length and other properties for decoration and callback. - Then we declare the
keyboardTypeandtextContentTypeto 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)
}
}
- We will render our text using
UILabelshere and someCALayersfor the bottom place marker. - We can have one variable
currentIndexto track the current cursor position. - 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
}
}
- We need some way to make our field first responder. So we initialize a tap gesture and add to ourself.
- 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()
}
- When user taps our field we make this as a
firstResponderso 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)
}
}
textCountis the computed property to return the all the characters count we have at the moment.- First requirement of protocol. We simply return compare with the
textCountproperty we created above. insertText(_:)method will insert the new text at thecurrentIndex's label and update cursor position.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.