Skip to content

Instantly share code, notes, and snippets.

@kemchenj
Last active December 11, 2023 07:19
Show Gist options
  • Save kemchenj/bc51eb610059c49a26d08bdc73d4743b to your computer and use it in GitHub Desktop.
Save kemchenj/bc51eb610059c49a26d08bdc73d4743b to your computer and use it in GitHub Desktop.
CodeTextField
import UIKit
class CodeTextField: UITextField, UITextFieldDelegate {
let codeLength: Int
var characterSize: CGSize
var characterSpacing: CGFloat
let textPreprocess: (String) -> String
let validCharacterSet: CharacterSet
let characterLabels: [CharacterLabel]
override var textColor: UIColor? {
get { return characterLabels.first?.textColor }
set { characterLabels.forEach { $0.textColor = newValue } }
}
override var delegate: UITextFieldDelegate? {
get { return super.delegate }
set { assertionFailure() }
}
init(
codeLength: Int,
characterSize: CGSize,
characterSpacing: CGFloat,
validCharacterSet: CharacterSet,
characterLabelGenerator: () -> CharacterLabel,
textPreprocess: @escaping (String) -> String = { $0 }
) {
self.codeLength = codeLength
self.characterSize = characterSize
self.characterSpacing = characterSpacing
self.validCharacterSet = validCharacterSet
self.textPreprocess = textPreprocess
self.characterLabels = (0..<codeLength).map { _ in characterLabelGenerator() }
super.init(frame: .zero)
loadSubviews()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
return CGSize(
width: characterSize.width * CGFloat(codeLength) + characterSpacing * CGFloat(codeLength - 1),
height: characterSize.height
)
}
private func loadSubviews() {
super.textColor = UIColor.clear
clipsToBounds = true
super.delegate = self
addTarget(self, action: #selector(updateLabels), for: .editingChanged)
clearsOnBeginEditing = false
clearsOnInsertion = false
characterLabels.forEach {
$0.textAlignment = .center
addSubview($0)
}
}
override func caretRect(for position: UITextPosition) -> CGRect {
let currentEditingPosition = text?.count ?? 0
let superRect = super.caretRect(for: position)
guard currentEditingPosition < codeLength else {
return CGRect(origin: .zero, size: .zero)
}
let x = (characterSize.width + characterSpacing) * CGFloat(currentEditingPosition) + characterSize.width / 2 - superRect.width / 2
return CGRect(
x: x,
y: superRect.minY,
width: superRect.width,
height: superRect.height
)
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
let origin = super.textRect(forBounds: bounds)
return CGRect(
x: -bounds.width,
y: 0,
width: 0,
height: origin.height
)
}
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
return .zero
}
override func borderRect(forBounds bounds: CGRect) -> CGRect {
return .zero
}
override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
return []
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let newText = text
.map { $0 as NSString }
.map { $0.replacingCharacters(in: range, with: string) }
.map(textPreprocess) ?? ""
let newTextCharacterSet = CharacterSet(charactersIn: newText)
let isValidLength = newText.count <= codeLength
let isUsingValidCharacterSet = validCharacterSet.isSuperset(of: newTextCharacterSet)
if isValidLength, isUsingValidCharacterSet {
textField.text = newText
sendActions(for: .editingChanged)
}
return false
}
override func deleteBackward() {
super.deleteBackward()
sendActions(for: .editingChanged)
}
@objc func updateLabels() {
let text = self.text ?? ""
var chars = text.map { Optional.some($0) }
while chars.count < codeLength {
chars.append(nil)
}
zip(chars, characterLabels).enumerated().forEach { args in
let (index, (char, charLabel)) = args
charLabel.update(
character: char,
isFocusingCharacter: index == text.count || (index == text.count - 1 && index == codeLength - 1),
isEditing: isEditing
)
}
}
override func becomeFirstResponder() -> Bool {
defer { updateLabels() }
return super.becomeFirstResponder()
}
override func resignFirstResponder() -> Bool {
defer { updateLabels() }
return super.resignFirstResponder()
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
let paste = #selector(paste(_:))
return action == paste
}
// 任何调整选择范围的行为都会直接把 insert point 调到最后一次
override var selectedTextRange: UITextRange? {
get { return super.selectedTextRange }
set { super.selectedTextRange = textRange(from: endOfDocument, to: endOfDocument) }
}
override func paste(_ sender: Any?) {
super.paste(sender)
updateLabels()
}
override func layoutSubviews() {
super.layoutSubviews()
characterLabels.enumerated().forEach { args in
let (index, label) = args
label.frame = CGRect(
x: (characterSize.width + characterSpacing) * CGFloat(index),
y: 0,
width: characterSize.width,
height: characterSize.height
)
}
}
class CharacterLabel: UILabel {
var isEditing = false
var isFocusingCharacter = false
func update(character: Character?, isFocusingCharacter: Bool, isEditing: Bool) {
self.text = character.map { String($0) }
self.isEditing = isEditing
self.isFocusingCharacter = isFocusingCharacter
}
}
}
@sunshineLixun
Copy link

最近正好要用到。之前实现了一版。感觉不太好,前来学习

@ruby109
Copy link

ruby109 commented Dec 16, 2019

115行 blog 中是「<=」,代码中是「<」。测试下来「<」最后一位会显示不出来,应该 blog 中的是对的。

@kemchenj
Copy link
Author

115行 blog 中是「<=」,代码中是「<」。测试下来「<」最后一位会显示不出来,应该 blog 中的是对的。

嗯,看了一下确实是,感谢指正。

@zhufaming
Copy link

有使用的demo

@zhufaming
Copy link

怎么使用的?

@ssmao
Copy link

ssmao commented May 27, 2020

iOS 10上发现一个bug,开始输入验证码的时候textField的内容没有被隐藏,收起一次键盘后就好了

@ws1227
Copy link

ws1227 commented Nov 27, 2020

请问怎么用啊

@ZClee128
Copy link

如何监听输入完了

@KPDeng
Copy link

KPDeng commented Oct 20, 2023

有注释或者使用实例就完美了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment