├── .gitignore ├── Package.swift ├── README.md └── Sources └── OneTimeCodeTextField ├── OneTimeCodeTextField.swift ├── OneTimeCodeTextFieldDelegate.swift └── OneTimeCodeTextFieldExtension.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "OneTimeCodeTextField", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "OneTimeCodeTextField", 15 | targets: ["OneTimeCodeTextField"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "OneTimeCodeTextField", 26 | dependencies: []) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OneTimeCodeTextField 2 |

3 | 4 |

5 | 6 | ### Example configuration 7 | 8 | ``` 9 | class ViewController: UIViewController { 10 | @IBOutlet weak var codeTxt: OneTimeCodeTextField! 11 | 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | 15 | // Configure has to be called first 16 | codeTxt.configure(withSlotCount: 6, andSpacing: 8) // Default: 6 slots, 8 spacing 17 | 18 | 19 | // Customisation(Optional) 20 | codeTxt.codeBackgroundColor = .secondarySystemBackground // Default: .secondarySystemBackground 21 | codeTxt.codeTextColor = .label // Default: .label 22 | codeTxt.codeFont = .systemFont(ofSize: 30, weight: .black) // Default: .system(ofSize: 24) 23 | codeTxt.codeMinimumScaleFactor = 0.2 // Default: 0.8 24 | 25 | codeTxt.codeCornerRadius = 12 // Default: 8 26 | codeTxt.codeCornerCurve = .continuous // Default: .continuous 27 | 28 | codeTxt.codeBorderWidth = 1 // Default: 0 29 | codeTxt.codeBorderColor = .label // Default: .none 30 | 31 | // Allow none-numeric code 32 | codeTxt.oneTimeCodeDelegate.allowedCharacters = .alphanumerics // Default: .decimalDigits 33 | 34 | //You should also specify which corresponding keyboard should be shown 35 | codeTxt.keyboardType = .asciiCapable // Default: .numberPad 36 | 37 | // Get entered Passcode 38 | codeTxt.didReceiveCode = { code in 39 | print(code) 40 | } 41 | 42 | // Clear textfield 43 | codeTxt.clear() 44 | } 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /Sources/OneTimeCodeTextField/OneTimeCodeTextField.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class OneTimeCodeTextField: UITextField { 4 | // MARK: UI Components 5 | public var digitLabels = [UILabel]() 6 | 7 | // MARK: Delegates 8 | public lazy var oneTimeCodeDelegate = OneTimeCodeTextFieldDelegate(oneTimeCodeTextField: self) 9 | 10 | // MARK: Properties 11 | private var isConfigured = false 12 | private lazy var tapGestureRecognizer: UITapGestureRecognizer = { 13 | let recognizer = UITapGestureRecognizer() 14 | recognizer.addTarget(self, action: #selector(becomeFirstResponder)) 15 | return recognizer 16 | }() 17 | 18 | // MARK: Completions 19 | public var didReceiveCode: ((String) -> Void)? 20 | 21 | // MARK: Customisations 22 | /// Needs to be called after `configure()`. 23 | /// Default value: `.secondarySystemBackground` 24 | public var codeBackgroundColor: UIColor = .secondarySystemBackground { 25 | didSet { 26 | digitLabels.forEach({ $0.backgroundColor = codeBackgroundColor }) 27 | } 28 | } 29 | 30 | /// Needs to be called after `configure()`. 31 | /// Default value: `.label` 32 | public var codeTextColor: UIColor = .label { 33 | didSet { 34 | digitLabels.forEach({ $0.textColor = codeTextColor }) 35 | } 36 | } 37 | 38 | /// Needs to be called after `configure()`. 39 | /// Default value: `.systemFont(ofSize: 24)` 40 | public var codeFont: UIFont = .systemFont(ofSize: 24) { 41 | didSet { 42 | digitLabels.forEach({ $0.font = codeFont }) 43 | } 44 | } 45 | 46 | /// Needs to be called after `configure()`. 47 | /// Default value: 0.8 48 | public var codeMinimumScaleFactor: CGFloat = 0.8 { 49 | didSet { 50 | digitLabels.forEach({ $0.minimumScaleFactor = codeMinimumScaleFactor }) 51 | } 52 | } 53 | 54 | /// Needs to be called after `configure()`. 55 | /// Default value: 8 56 | public var codeCornerRadius: CGFloat = 8 { 57 | didSet { 58 | digitLabels.forEach({ $0.layer.cornerRadius = codeCornerRadius }) 59 | } 60 | } 61 | 62 | /// Needs to be called after `configure()`. 63 | /// Default value: `.continuous` 64 | public var codeCornerCurve: CALayerCornerCurve = .continuous { 65 | didSet { 66 | digitLabels.forEach({ $0.layer.cornerCurve = codeCornerCurve }) 67 | } 68 | } 69 | 70 | /// Needs to be called after `configure()`. 71 | /// Default value: 0 72 | public var codeBorderWidth: CGFloat = 0 { 73 | didSet { 74 | digitLabels.forEach({ $0.layer.borderWidth = codeBorderWidth }) 75 | } 76 | } 77 | 78 | /// Needs to be called after `configure()`. 79 | /// Default value: `.none` 80 | public var codeBorderColor: UIColor? = .none { 81 | didSet { 82 | digitLabels.forEach({ $0.layer.borderColor = codeBorderColor?.cgColor }) 83 | } 84 | } 85 | 86 | // MARK: Configuration 87 | public func configure(withSlotCount slotCount: Int = 6, andSpacing spacing: CGFloat = 8) { 88 | guard isConfigured == false else { return } 89 | isConfigured = true 90 | configureTextField() 91 | 92 | let slotsStackView = generateSlotsStackView(with: slotCount, spacing: spacing) 93 | addSubview(slotsStackView) 94 | addGestureRecognizer(tapGestureRecognizer) 95 | 96 | NSLayoutConstraint.activate([ 97 | slotsStackView.topAnchor.constraint(equalTo: topAnchor), 98 | slotsStackView.leadingAnchor.constraint(equalTo: leadingAnchor), 99 | slotsStackView.trailingAnchor.constraint(equalTo: trailingAnchor), 100 | slotsStackView.bottomAnchor.constraint(equalTo: bottomAnchor) 101 | ]) 102 | } 103 | 104 | private func configureTextField() { 105 | tintColor = .clear 106 | textColor = .clear 107 | layer.borderWidth = 0 108 | borderStyle = .none 109 | keyboardType = .numberPad 110 | textContentType = .oneTimeCode 111 | 112 | addTarget(self, action: #selector(textDidChange), for: .editingChanged) 113 | delegate = oneTimeCodeDelegate 114 | 115 | becomeFirstResponder() 116 | } 117 | 118 | private func generateSlotsStackView(with count: Int, spacing: CGFloat) -> UIStackView { 119 | let stackView = UIStackView() 120 | stackView.translatesAutoresizingMaskIntoConstraints = false 121 | stackView.axis = .horizontal 122 | stackView.alignment = .fill 123 | stackView.distribution = .fillEqually 124 | stackView.spacing = spacing 125 | 126 | for _ in 0.. UILabel { 136 | let label = UILabel() 137 | label.translatesAutoresizingMaskIntoConstraints = false 138 | label.isUserInteractionEnabled = true 139 | label.textAlignment = .center 140 | label.font = codeFont 141 | label.numberOfLines = 0 142 | label.adjustsFontSizeToFitWidth = true 143 | label.minimumScaleFactor = 0.8 144 | label.textColor = codeTextColor 145 | label.backgroundColor = codeBackgroundColor 146 | 147 | label.layer.masksToBounds = true 148 | label.layer.cornerRadius = codeCornerRadius 149 | label.layer.cornerCurve = codeCornerCurve 150 | 151 | label.layer.borderWidth = codeBorderWidth 152 | label.layer.borderColor = codeBorderColor?.cgColor 153 | 154 | return label 155 | } 156 | 157 | @objc 158 | private func textDidChange() { 159 | guard let code = text, code.count <= digitLabels.count else { return } 160 | 161 | for i in 0 ..< digitLabels.count { 162 | let currentLabel = digitLabels[i] 163 | 164 | if i < code.count { 165 | let index = code.index(code.startIndex, offsetBy: i) 166 | currentLabel.text = String(code[index]).uppercased() 167 | } else { 168 | currentLabel.text?.removeAll() 169 | } 170 | } 171 | 172 | if code.count == digitLabels.count { didReceiveCode?(code) } 173 | } 174 | 175 | public func clear() { 176 | guard isConfigured == true else { return } 177 | digitLabels.forEach({ $0.text = "" }) 178 | text = "" 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Sources/OneTimeCodeTextField/OneTimeCodeTextFieldDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public class OneTimeCodeTextFieldDelegate: NSObject, UITextFieldDelegate { 4 | 5 | public var allowedCharacters: CharacterSet = .decimalDigits 6 | 7 | let oneTimeCodeTextField: OneTimeCodeTextField 8 | 9 | init(oneTimeCodeTextField: OneTimeCodeTextField) { 10 | self.oneTimeCodeTextField = oneTimeCodeTextField 11 | } 12 | 13 | public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 14 | guard allowedCharacters.isSuperset(of: CharacterSet(charactersIn: string)), 15 | let characterCount = textField.text?.count else { return false } 16 | return characterCount < oneTimeCodeTextField.digitLabels.count || string == "" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/OneTimeCodeTextField/OneTimeCodeTextFieldExtension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension OneTimeCodeTextField { 4 | public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 5 | false 6 | } 7 | 8 | public override func caretRect(for position: UITextPosition) -> CGRect { 9 | .zero 10 | } 11 | 12 | public override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { 13 | [] 14 | } 15 | } 16 | --------------------------------------------------------------------------------