// // CodeInputView.swift // CovidSafe // // Copyright © 2020 Australian Government. All rights reserved. // import UIKit public enum CodeInputViewAnimationType: Int { case none, dissolve, spring } public protocol CodeInputViewDelegate: class { func codeDidChange(codeInputView: CodeInputView) func codeDidFinish(codeInputView: CodeInputView) } open class CodeInputView: UIView { /** The number of digits to show, which will be the maximum length of the final string */ open var numberOfDigits: Int = 6 { didSet { setup() } } /** When the code is invalid, change colour of the underline beneath all numbers */ open var invalidCode: Bool = false { didSet { updateUnderlineColors() } } /** The color of the line under each digit */ open var bottomBorderColor = UIColor.lightGray { didSet { setup() } } /** The color of the line under digits when there is an error with the code */ var errorBottomBorderColor = UIColor(0xA31919) /** The color of the line under next digit */ open var nextDigitBottomBorderColor = UIColor.gray { didSet { setup() } } /** The color of the digits */ open var textColor: UIColor = UIColor.covidSafeLighterColor { didSet { setup() } } /** If not nil, only the characters in this string are acceptable. The rest will be ignored. */ open var acceptableCharacters: String? = nil /** The keyboard type that shows up when entering characters */ open var keyboardType: UIKeyboardType = .numberPad { didSet { setup() } } /** Keyboard appearance type. `default` or `light`, `dark` and `alert`. */ open var keyboardAppearance: UIKeyboardAppearance = .default { didSet { setup() } } /** UITextField text content type. Enables and disables one time code. */ open var isOneTimeCode: Bool = false { didSet { setup() } } /// The animatino to use to show new digits open var animationType: CodeInputViewAnimationType = .none /** The font of the digits. Although font size will be calculated automatically. */ open var font: UIFont? = UIFont(name: "SFUIDisplay-Medium", size: 16) /** The string that the user has entered */ open var text: String { get { guard let textField = textField else { return "" } return textField.text ?? "" } } open weak var delegate: CodeInputViewDelegate? fileprivate var labels = [UILabel]() fileprivate var underlines = [UIView]() fileprivate var textField: UITextField? fileprivate var tapGestureRecognizer: UITapGestureRecognizer? fileprivate var underlineHeight: CGFloat = 1 fileprivate var spacing: CGFloat = 8 override open var canBecomeFirstResponder: Bool { get { return true } } override init(frame: CGRect) { super.init(frame: frame) setup() } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setup() } override open func becomeFirstResponder() -> Bool { guard let textField = textField else { return false } textField.becomeFirstResponder() return true } override open func resignFirstResponder() -> Bool { guard let textField = textField else { return true } textField.resignFirstResponder() return true } override open func layoutSubviews() { super.layoutSubviews() // width to height ratio let ratio: CGFloat = 0.75 // Now we find the optimal font size based on the view size // and set the frame for the labels var characterWidth = frame.height * ratio var characterHeight = frame.height // if using the current width, the digits go off the view, recalculate // based on width instead of height if (characterWidth + spacing) * CGFloat(numberOfDigits) + spacing > frame.width { characterWidth = (frame.width - spacing * CGFloat(numberOfDigits + 1)) / CGFloat(numberOfDigits) characterHeight = characterWidth / ratio } let extraSpace = frame.width - CGFloat(numberOfDigits - 1) * spacing - CGFloat(numberOfDigits) * characterWidth // font size should be less than the available vertical space let fontSize = characterHeight * 0.8 let y = (frame.height - characterHeight) / 2 for (index, label) in labels.enumerated() { let x = extraSpace / 2 + (characterWidth + spacing) * CGFloat(index) label.frame = CGRect(x: x, y: y, width: characterWidth, height: characterHeight) underlines[index].frame = CGRect(x: x, y: frame.height - underlineHeight, width: characterWidth, height: underlineHeight) if let font = font { label.font = font.withSize(fontSize) } else { label.font = label.font.withSize(fontSize) } } } /** Sets up the required views */ fileprivate func setup() { isUserInteractionEnabled = true clipsToBounds = true if tapGestureRecognizer == nil { tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(viewTapped(_:))) addGestureRecognizer(tapGestureRecognizer!) } if textField == nil { textField = UITextField() textField?.delegate = self textField?.frame = CGRect(x: 0, y: -40, width: 100, height: 30) addSubview(textField!) } textField?.accessibilityLabel = self.accessibilityLabel textField?.keyboardType = keyboardType textField?.keyboardAppearance = keyboardAppearance // Enabling/Disabling one time code // .oneTimeCode content type available on iOS 12 and above devices // One time code if isOneTimeCode { if #available(iOS 12.0, *) { textField?.textContentType = .oneTimeCode } } else{ textField?.textContentType = nil } // Since this function isn't called frequently, we just remove everything // and recreate them. Don't need to optimize it. for label in labels { label.removeFromSuperview() } labels.removeAll() for underline in underlines { underline.removeFromSuperview() } underlines.removeAll() for i in 0.. index { let animate = index == text.count - 1 && !backspaced changeText(of: labels[index], newText: String(item), animate) } } // set all the bottom borders color to default for underline in underlines { underline.backgroundColor = bottomBorderColor } let nextIndex = text.count + 1 if labels.count > 0, nextIndex < labels.count + 1 { // set the next digit bottom border color underlines[nextIndex - 1].backgroundColor = nextDigitBottomBorderColor } else { delegate?.codeDidFinish(codeInputView: self) } delegate?.codeDidChange(codeInputView: self) } /// Changes the text of a UILabel with animation /// /// - parameter label: The label to change text of /// - parameter newText: The new string for the label private func changeText(of label: UILabel, newText: String, _ animated: Bool = false) { if !animated || animationType == .none { label.text = newText return } if animationType == .spring { label.frame.origin.y = frame.height label.text = newText UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.1, initialSpringVelocity: 1, options: .curveEaseInOut, animations: { label.frame.origin.y = self.frame.height - label.frame.height }, completion: nil) } else if animationType == .dissolve { UIView.transition(with: label, duration: 0.4, options: .transitionCrossDissolve, animations: { label.text = newText }, completion: nil) } } } // MARK: TextField Delegate extension CodeInputView: UITextFieldDelegate { public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let char = string.cString(using: .utf8) let isBackSpace = strcmp(char, "\\b") if isBackSpace == -92 && textField.text?.count ?? 0 > 0 { textField.text!.removeLast() didChange(true) return false } if textField.text?.count ?? 0 >= numberOfDigits { return false } if textField.text?.count ?? 0 == (numberOfDigits - 1) { textField.resignFirstResponder() } guard let acceptableCharacters = acceptableCharacters else { textField.text = (textField.text ?? "") + string didChange() return false } if acceptableCharacters.contains(string) { textField.text = (textField.text ?? "") + string didChange() return false } return false } }