As a iOS engineer, you are asked to collect user’s input for a pin code. How can we make it?
You can simply use a UITextField to do that, but it doesn’t make sense that user can enter any characters and super long text as a pin code input.
We can use a customized UIView to reach this. First of all, we create a new file named PinView:
@IBDesignable class PinView: UIView { // 1
public var isIntact = false // 2
var pinCode: String? // 3
@IBInspectable var maxDigits: Int = 4 // 4
}
Let’s break down it into several pieces:
var isIntact
denote if the pin code is complete.var pinCode
represent the pin code which is the user’s current input.var maxDigits
represent the maximum allowed length of the pin code. We decorate this vairable with a IBInspectable
means we can see and modify its value in IB’s attribute inspector.Apart from that, we also need some IBInspectable variables to customize its appearance:
@IBInspectable var digitWidth: CGFloat = 44 // 1
@IBInspectable var digitHeight: CGFloat = 44 // 2
@IBInspectable var gapBetweenDigits: CGFloat = 10 // 3
@IBInspectable var digitBorderWidth: CGFloat = 2 // 4
@IBInspectable var digitBorderColor: UIColor = .black // 5
@IBInspectable var digitCornerRadius: CGFloat = 0 // 6
@IBInspectable var textColor: UIColor = .black // 7
@IBInspectable var font: UIFont = UIFont.boldSystemFont(ofSize: 23) // 8
Let’s break it down step by step:
width
and:height
.gap
between a text field and other one.Then we implement the initialization method:
override public func awakeFromNib() {
setupDigitFields()
}
override public func prepareForInterfaceBuilder() {
setupDigitFields()
}
awakeFromNib
will be called when PinView is loaded into storyboard if you are using storyboard to implement app’s UI.
prepareForInterfaceBuilder
will be invoked when IB is ready to update the storyboard’s canvas.
setupDigitFields
method is really responsible for class initialization:
fileprivate func setupDigitFields() {
// 1
backgroundColor = .clear
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.alignment = .leading
stackView.spacing = gapBetweenDigits
stackView.distribution = .fillProportionally
addSubview(stackView)
// 2
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
stackView.widthAnchor.constraint(equalTo: widthAnchor),
stackView.heightAnchor.constraint(equalTo: heightAnchor)
])
// 3
for tag in 1...maxDigits {
let textField = UITextField() // 4
textField.tag = tag // 5
// 6
stackView.addArrangedSubview(textField)
NSLayoutConstraint.activate([
textField.widthAnchor.constraint(equalToConstant: digitWidth),
textField.heightAnchor.constraint(equalToConstant: digitHeight)
])
setupDigitFieldStyle(textField) // 7
}
}
We will break down this snippet for you:
maxDigits
.tag
a certain number by their orders.digitWidth
and digitHeight
.Then it’s the setupDigitFieldStyle function:
fileprivate func setupDigitFieldStyle(_ textField: UITextField) {
textField.delegate = self
textField.backgroundColor = .white
textField.keyboardType = .numberPad
textField.textAlignment = .center
textField.contentHorizontalAlignment = .center
textField.layer.cornerRadius = digitCornerRadius
textField.textColor = textColor
textField.font = font
textField.layer.borderWidth = digitBorderWidth
}
It’s no need to discuss the function, it’s all about UI styles. The most important line is :
textField.delegate = self
So PinView need to adapt UITextFieldDelegate protocol:
extension PinView: UITextFieldDelegate {
// 1
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
var next = 0
if string.isEmpty { // 2
textField.deleteBackward()
getPinCode()
return false
} else if string.count == 1 { // 3
textField.text = string
next = textField.tag + 1
getPinCode()
} else if string.count == maxDigits { // 4
var mString = string
for tag in 1...maxDigits {
guard let textfield = viewWithTag(tag) as? UITextField else { continue }
textfield.text = String(mString.removeFirst())
}
getPinCode()
}
if let nextDigitField = viewWithTag(next) as? UITextField { // 5
nextDigitField.becomeFirstResponder()
} else { // 6
endEditing(true)
}
return false // 7
}
}
I’d like to break down these codes into :
string
is empty. In swift 5.3, when the user deletes one or more characters, the replacement string is empty.If user is pressing the backspace key(‘del’ key on iOS keyboard), delete backward, figure out what the pin code actually is, and return false.It’s necessary to define a delegate protocol to PinView so that we can get notice from outside when its state changes.
public protocol PinViewDelegate {
func didChangePinCode(_ pinView: PinView)
}
didChangePinCode function has a parameter pinView
, through this parameter, we can get all information about specific PinView instance.
PinView also need a variable to refer to a PinViewDelegate:
public var delegate: PinViewDelegate?
Next, it is the getPinCode
function:
public func getPinCode() {
var pin = ""
for tag in 1...maxDigits {
guard let textfield = viewWithTag(tag) as? UITextField else { continue }
pin += textfield.text!
}
pinCode = pin
self.isIntact = pin.count >= maxDigits
delegate?.didChangePinCode(self)
}
As we mentioned before, PinView is made up of more than one text field, so we add up all text field’s text to a string that is where the pinCode comes in. We also compute isIntact
correspondingly. At the end of the function, we call the delegate method to notify changes.
Alright, It’s time to use our brand new PinView control.
Open main.sotryboard, drag 2 UIViews in canvas, and set their class as PinView:
Set their x,y and height constraints, but keep the width constraint empty because we want it self-adapting.
Drag and drop a UIView into storyboard canvas:
Config constraints for it.
Create 3 IBOutlets for them:
class ViewController: UIViewController {
@IBOutlet weak var infoView: UIView!
@IBOutlet weak var pinView2: PinView!
@IBOutlet weak var pinView1: PinView!
At the end of viewDidLoad
, add lines:
pinView1.delegate = self
pinView2.delegate = self
Extend ViewController to adapt PinViewDelegate:
extension ViewController: PinViewDelegate {
func didChangePinCode(_ pinView: PinView) {
if pinView1.isIntact && pinView2.isIntact { // 1
infoView.isHidden = false
if pinView1.pinCode == pinView2.pinCode { // 2
infoView.backgroundColor = .green
}else{
infoView.backgroundColor = .red
}
}else{
infoView.isHidden = true
}
}
}
Break down these lines into steps :
infoView
.infoView
to green , or else change it to red.infoView
.Build & run, let’s give it a shot!
As you see, once you enter a number in PinView, the focus will move on to next text field automatically. But it’s not that case if you enter a backspace! The focus - cursor position didn’t move back.
Let’s fix it finally. Add a new swift file in project:
import UIKit
class PinDigitField: UITextField {
override func deleteBackward() {
if self.text?.count == 0 {
moveToPrev()
}else {
self.text = nil
moveToPrev()
}
}
func moveToPrev() {
if let prev = self.superview?.viewWithTag(self.tag - 1) as? UITextField {
prev.becomeFirstResponder()
}
}
}
deleteBackward
function comes from UITextInput protocol. We override this function to rephrase the behavior when backspace is presssed.
moveToPrev
function look up previous text field by tag index in PinView and move cursor position back to it.
Back to PinView.swift, find setupDigitFields function, replace UITextField with PinDigitField:
for tag in 1...maxDigits {
let textField = PinDigitField() // UITextField()
Build & run, give it a go!