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
UIControl
just like UITextField. - Then we declare some
FieldState
for rendering text states at particular location. - We define
digit
to allow different passcode length and other properties for decoration and callback. - Then we declare the
keyboardType
andtextContentType
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)
}
}
- We will render our text using
UILabels
here and someCALayers
for the bottom place marker. - We can have one variable
currentIndex
to 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
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)
}
}
textCount
is 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
textCount
property 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.