Skip to content

Commit

Permalink
Add initial working code
Browse files Browse the repository at this point in the history
  • Loading branch information
theoriginalbit committed Apr 15, 2021
1 parent b93d20c commit 05cd5d8
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 0 deletions.
16 changes: 16 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// swift-tools-version:5.3

import PackageDescription

let package = Package(
name: "OneTimePasscodeField",
platforms: [
.iOS(.v13),
],
products: [
.library(name: "OneTimePasscodeField", targets: ["OneTimePasscodeField"]),
],
targets: [
.target(name: "OneTimePasscodeField"),
]
)
128 changes: 128 additions & 0 deletions Sources/OneTimePasscodeField/OneTimePasscodeField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import UIKit

public class OneTimePasscodeField: UIControl {
private let contentStack = UIStackView()

private var textFields: [OneTimePasscodeTextField] {
// swiftlint:disable:next force_cast
contentStack.arrangedSubviews as! [OneTimePasscodeTextField]
}

// swiftlint:disable:next weak_delegate
private lazy var textFieldDelegate = OneTimePasscodeTextFieldDelegate(parentField: self)

public var text: String {
get { textFields.compactMap(\.text).joined() }
set { autoFillTextField(with: newValue) }
}

@objc public dynamic var textColor = UIColor.black {
didSet {
textFields.forEach {
$0.textColor = textColor
}
}
}

@objc public dynamic var textBackgroundColor = UIColor(white: 1, alpha: 0.5) {
didSet {
textFields.forEach {
$0.backgroundColor = textBackgroundColor
}
}
}

@objc public dynamic var font = UIFont.preferredFont(forTextStyle: .largeTitle) {
didSet {
textFields.forEach {
$0.font = font
}
}
}

override public init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}

public required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}

private func commonInit() {
// Handle touches inside this view, which without the following wouldn't trigger text entry
addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)

contentStack.translatesAutoresizingMaskIntoConstraints = false
contentStack.isUserInteractionEnabled = false
contentStack.contentMode = .center
contentStack.distribution = .fillEqually
contentStack.alignment = .fill
contentStack.spacing = 10
addSubview(contentStack)

NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: topAnchor),
contentStack.trailingAnchor.constraint(equalTo: trailingAnchor),
contentStack.bottomAnchor.constraint(equalTo: bottomAnchor),
contentStack.leadingAnchor.constraint(equalTo: leadingAnchor),
])

// Set up the text fields
var previousTextField: OneTimePasscodeTextField?
for _ in 0 ..< 6 {
let textField = OneTimePasscodeTextField()
textField.delegate = textFieldDelegate
textField.backgroundColor = textBackgroundColor
textField.font = font
contentStack.addArrangedSubview(textField)

// Create the linked list of fields
previousTextField?.nextTextField = textField
textField.previousTextField = previousTextField
previousTextField = textField
}
}

@objc private func touchUpInside() {
_ = becomeFirstResponder()
}

override public func becomeFirstResponder() -> Bool {
// Make sure to select the first empty text field, don't let the user start typing in the middle

// swiftlint:disable:next force_unwrapping
guard let emptyOrLastField = textFields.first(where: { $0.text!.isEmpty }) ?? textFields.last else {
return super.becomeFirstResponder()
}
return emptyOrLastField.becomeFirstResponder()
}

override public func resignFirstResponder() -> Bool {
textFields.first(where: \.isFirstResponder)?.resignFirstResponder() ?? true
}

@discardableResult
func autoFillTextField(with string: String) -> Bool {
// Only allow numbers to be entered
guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) else {
return false
}

// Distribute the characters into each of the textfields
var reversedCharacters = string.reversed().compactMap { String($0) }
for textField in textFields {
guard let char = reversedCharacters.popLast() else { break }
textField.text = String(char)
}

return true
}

public func clearTextFields() {
for textField in textFields {
textField.text = ""
}
}
}
37 changes: 37 additions & 0 deletions Sources/OneTimePasscodeField/OneTimePasscodeTextField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import UIKit

class OneTimePasscodeTextField: UITextField {
weak var previousTextField: OneTimePasscodeTextField?
weak var nextTextField: OneTimePasscodeTextField?

override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}

private func commonInit() {
textAlignment = .center
layer.cornerRadius = 8
keyboardType = .numberPad
autocorrectionType = .yes
textContentType = .oneTimeCode
}

override func deleteBackward() {
// If this text field is empty then we need to clear the previous text field and make it
// the responder, doing so empties the text field and puts the text cursor into it.
// This makes pressing backspace feel more natural. It also makes sure the user can
// backspace the current field to type a new character.
if let text = text, text.isEmpty {
previousTextField?.text = ""
previousTextField?.becomeFirstResponder()
} else {
text = ""
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import UIKit

class OneTimePasscodeTextFieldDelegate: NSObject, UITextFieldDelegate {
let parentField: OneTimePasscodeField

init(parentField: OneTimePasscodeField) {
self.parentField = parentField
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
// ensure the input is only numbers
guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) else {
return false
}

// swiftlint:disable:next force_cast
let textField = textField as! OneTimePasscodeTextField

if string.count > 1 {
// A code is being pasted or auto-filled
textField.resignFirstResponder()
parentField.autoFillTextField(with: string)
return false
}

if string.count == 1 {
if textField.nextTextField == nil {
textField.text? = string
textField.resignFirstResponder()
} else {
textField.text? = string
textField.nextTextField?.becomeFirstResponder()
}
return false
}

return true
}
}

0 comments on commit 05cd5d8

Please sign in to comment.