Skip to content

Commit 05cd5d8

Browse files
Add initial working code
1 parent b93d20c commit 05cd5d8

4 files changed

Lines changed: 220 additions & 0 deletions

File tree

Package.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// swift-tools-version:5.3
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "OneTimePasscodeField",
7+
platforms: [
8+
.iOS(.v13),
9+
],
10+
products: [
11+
.library(name: "OneTimePasscodeField", targets: ["OneTimePasscodeField"]),
12+
],
13+
targets: [
14+
.target(name: "OneTimePasscodeField"),
15+
]
16+
)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import UIKit
2+
3+
public class OneTimePasscodeField: UIControl {
4+
private let contentStack = UIStackView()
5+
6+
private var textFields: [OneTimePasscodeTextField] {
7+
// swiftlint:disable:next force_cast
8+
contentStack.arrangedSubviews as! [OneTimePasscodeTextField]
9+
}
10+
11+
// swiftlint:disable:next weak_delegate
12+
private lazy var textFieldDelegate = OneTimePasscodeTextFieldDelegate(parentField: self)
13+
14+
public var text: String {
15+
get { textFields.compactMap(\.text).joined() }
16+
set { autoFillTextField(with: newValue) }
17+
}
18+
19+
@objc public dynamic var textColor = UIColor.black {
20+
didSet {
21+
textFields.forEach {
22+
$0.textColor = textColor
23+
}
24+
}
25+
}
26+
27+
@objc public dynamic var textBackgroundColor = UIColor(white: 1, alpha: 0.5) {
28+
didSet {
29+
textFields.forEach {
30+
$0.backgroundColor = textBackgroundColor
31+
}
32+
}
33+
}
34+
35+
@objc public dynamic var font = UIFont.preferredFont(forTextStyle: .largeTitle) {
36+
didSet {
37+
textFields.forEach {
38+
$0.font = font
39+
}
40+
}
41+
}
42+
43+
override public init(frame: CGRect) {
44+
super.init(frame: frame)
45+
commonInit()
46+
}
47+
48+
public required init?(coder: NSCoder) {
49+
super.init(coder: coder)
50+
commonInit()
51+
}
52+
53+
private func commonInit() {
54+
// Handle touches inside this view, which without the following wouldn't trigger text entry
55+
addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)
56+
57+
contentStack.translatesAutoresizingMaskIntoConstraints = false
58+
contentStack.isUserInteractionEnabled = false
59+
contentStack.contentMode = .center
60+
contentStack.distribution = .fillEqually
61+
contentStack.alignment = .fill
62+
contentStack.spacing = 10
63+
addSubview(contentStack)
64+
65+
NSLayoutConstraint.activate([
66+
contentStack.topAnchor.constraint(equalTo: topAnchor),
67+
contentStack.trailingAnchor.constraint(equalTo: trailingAnchor),
68+
contentStack.bottomAnchor.constraint(equalTo: bottomAnchor),
69+
contentStack.leadingAnchor.constraint(equalTo: leadingAnchor),
70+
])
71+
72+
// Set up the text fields
73+
var previousTextField: OneTimePasscodeTextField?
74+
for _ in 0 ..< 6 {
75+
let textField = OneTimePasscodeTextField()
76+
textField.delegate = textFieldDelegate
77+
textField.backgroundColor = textBackgroundColor
78+
textField.font = font
79+
contentStack.addArrangedSubview(textField)
80+
81+
// Create the linked list of fields
82+
previousTextField?.nextTextField = textField
83+
textField.previousTextField = previousTextField
84+
previousTextField = textField
85+
}
86+
}
87+
88+
@objc private func touchUpInside() {
89+
_ = becomeFirstResponder()
90+
}
91+
92+
override public func becomeFirstResponder() -> Bool {
93+
// Make sure to select the first empty text field, don't let the user start typing in the middle
94+
95+
// swiftlint:disable:next force_unwrapping
96+
guard let emptyOrLastField = textFields.first(where: { $0.text!.isEmpty }) ?? textFields.last else {
97+
return super.becomeFirstResponder()
98+
}
99+
return emptyOrLastField.becomeFirstResponder()
100+
}
101+
102+
override public func resignFirstResponder() -> Bool {
103+
textFields.first(where: \.isFirstResponder)?.resignFirstResponder() ?? true
104+
}
105+
106+
@discardableResult
107+
func autoFillTextField(with string: String) -> Bool {
108+
// Only allow numbers to be entered
109+
guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) else {
110+
return false
111+
}
112+
113+
// Distribute the characters into each of the textfields
114+
var reversedCharacters = string.reversed().compactMap { String($0) }
115+
for textField in textFields {
116+
guard let char = reversedCharacters.popLast() else { break }
117+
textField.text = String(char)
118+
}
119+
120+
return true
121+
}
122+
123+
public func clearTextFields() {
124+
for textField in textFields {
125+
textField.text = ""
126+
}
127+
}
128+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import UIKit
2+
3+
class OneTimePasscodeTextField: UITextField {
4+
weak var previousTextField: OneTimePasscodeTextField?
5+
weak var nextTextField: OneTimePasscodeTextField?
6+
7+
override init(frame: CGRect) {
8+
super.init(frame: frame)
9+
commonInit()
10+
}
11+
12+
required init?(coder: NSCoder) {
13+
super.init(coder: coder)
14+
commonInit()
15+
}
16+
17+
private func commonInit() {
18+
textAlignment = .center
19+
layer.cornerRadius = 8
20+
keyboardType = .numberPad
21+
autocorrectionType = .yes
22+
textContentType = .oneTimeCode
23+
}
24+
25+
override func deleteBackward() {
26+
// If this text field is empty then we need to clear the previous text field and make it
27+
// the responder, doing so empties the text field and puts the text cursor into it.
28+
// This makes pressing backspace feel more natural. It also makes sure the user can
29+
// backspace the current field to type a new character.
30+
if let text = text, text.isEmpty {
31+
previousTextField?.text = ""
32+
previousTextField?.becomeFirstResponder()
33+
} else {
34+
text = ""
35+
}
36+
}
37+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import UIKit
2+
3+
class OneTimePasscodeTextFieldDelegate: NSObject, UITextFieldDelegate {
4+
let parentField: OneTimePasscodeField
5+
6+
init(parentField: OneTimePasscodeField) {
7+
self.parentField = parentField
8+
}
9+
10+
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
11+
// ensure the input is only numbers
12+
guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) else {
13+
return false
14+
}
15+
16+
// swiftlint:disable:next force_cast
17+
let textField = textField as! OneTimePasscodeTextField
18+
19+
if string.count > 1 {
20+
// A code is being pasted or auto-filled
21+
textField.resignFirstResponder()
22+
parentField.autoFillTextField(with: string)
23+
return false
24+
}
25+
26+
if string.count == 1 {
27+
if textField.nextTextField == nil {
28+
textField.text? = string
29+
textField.resignFirstResponder()
30+
} else {
31+
textField.text? = string
32+
textField.nextTextField?.becomeFirstResponder()
33+
}
34+
return false
35+
}
36+
37+
return true
38+
}
39+
}

0 commit comments

Comments
 (0)