mobile-ios/CovidSafe/CodeInputView.swift
2020-05-15 18:24:06 +10:00

410 lines
11 KiB
Swift

//
// 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?.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..<numberOfDigits {
let label = UILabel()
label.textAlignment = .center
label.isUserInteractionEnabled = false
label.textColor = textColor
let underline = UIView()
underline.backgroundColor = i == 0 ? nextDigitBottomBorderColor : bottomBorderColor
addSubview(label)
addSubview(underline)
labels.append(label)
underlines.append(underline)
}
}
/**
Handles tap gesture on the view
*/
@objc fileprivate func viewTapped(_ sender: UITapGestureRecognizer) {
textField!.becomeFirstResponder()
}
fileprivate func updateUnderlineColors() {
for i in 0..<numberOfDigits {
underlines[i].backgroundColor = errorBottomBorderColor
}
}
/**
Called when the text changes so that the labels get updated
*/
fileprivate func didChange(_ backspaced: Bool = false) {
guard let textField = textField, let text = textField.text else { return }
for item in labels {
item.text = ""
}
for (index, item) in text.enumerated() {
if labels.count > 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
}
}