Last active
August 15, 2022 17:01
-
-
Save JasonCanCode/8e5dbb80ea6bbedcb084d0ec14d609b4 to your computer and use it in GitHub Desktop.
A helper class for handling the keyboard for views with one or more text fields
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
class TextFieldsKeyboardHandler: NSObject, UITextFieldDelegate { | |
private weak var delegate: TextFieldsKeyboardHandlerDelegate! | |
/// Used to resolve issues with old devices having offset y origins where nav bars are present | |
private var restingYOrigin: CGFloat = 0 | |
/// Replace for a different handling of the keyboard offset | |
lazy var updateKeyboardFrame: (CGRect) -> Void = updateViewOffset | |
init(delegate: TextFieldsKeyboardHandlerDelegate, shouldAddDismissGesture: Bool = true) { | |
self.delegate = delegate | |
super.init() | |
for textField in delegate.textFields { | |
textField.addTarget(self, action: #selector(editingChanged), for: .editingChanged) | |
textField.delegate = self | |
} | |
if shouldAddDismissGesture { | |
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.doDismissKeyboard (_:))) | |
tapGesture.cancelsTouchesInView = false | |
delegate.presentingView.addGestureRecognizer(tapGesture) | |
} | |
} | |
private func updateObservationOfKeyboard(shouldObserve: Bool) { | |
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil) | |
if shouldObserve { | |
NotificationCenter.default.addObserver(self, selector: #selector(gotKeyboardNotification), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) | |
} | |
} | |
@objc private func editingChanged() { | |
delegate.checkValidation() | |
} | |
@objc func doDismissKeyboard (_ sender: UITapGestureRecognizer) { | |
dismissKeyboard() | |
} | |
@objc func gotKeyboardNotification(notification: NSNotification) { | |
guard let currentKeyboardFrame: CGRect = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue, | |
let newKeyboardFrame: CGRect = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { | |
return | |
} | |
updateOriginIfNeeded(currentKeyboardFrame) | |
updateDatePickerIfNeeded(newKeyboardFrame) | |
updateKeyboardFrame(newKeyboardFrame) | |
} | |
private func updateOriginIfNeeded(_ currentKeyboardFrame: CGRect) { | |
let isKeyboardPresenting = currentKeyboardFrame.origin.y >= UIScreen.main.bounds.height | |
if isKeyboardPresenting { | |
restingYOrigin = delegate.presentingView.frame.origin.y | |
} | |
} | |
func updateViewOffset(forKeyboardFrame keyboardFrame: CGRect) { | |
let isKeyboardDismissing = keyboardFrame.origin.y >= UIScreen.main.bounds.height | |
let view = delegate.presentingView | |
guard let currentTextField = delegate.currentResponder, !isKeyboardDismissing else { | |
UIView.animate(withDuration: 0.2, animations: { | |
view.frame.origin.y = self.restingYOrigin | |
}, completion: { _ in | |
self.updateObservationOfKeyboard(shouldObserve: false) | |
}) | |
return | |
} | |
let containingView: UIView = currentTextField.superview ?? view | |
let textFieldFrame = containingView.convert(currentTextField.frame, to: view) | |
let textFieldOffset: CGFloat = textFieldFrame.maxY + 3 | |
let accessoryHeight: CGFloat = currentTextField.inputAccessoryView?.frame.size.height ?? 0 | |
let heightOfKeyboard = keyboardFrame.size.height | |
let offsetTextFieldFromBottom: CGFloat = view.frame.size.height - (textFieldOffset + accessoryHeight) | |
UIView.animate(withDuration: 0.2) { | |
var frame = view.frame | |
frame.origin.y = min(self.restingYOrigin, offsetTextFieldFromBottom - heightOfKeyboard) | |
view.frame = frame | |
} | |
} | |
func lastTextFieldDidReturn() { | |
dismissKeyboard() | |
delegate.lastTextFieldDidReturn() | |
} | |
func dismissKeyboard() { | |
for textField in delegate.textFields where textField.isFirstResponder { | |
textField.resignFirstResponder() | |
return | |
} | |
} | |
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { | |
updateObservationOfKeyboard(shouldObserve: true) | |
delegate.checkValidation() | |
previousFieldButton.isEnabled = textField != delegate.textFields.first | |
let isLastField = delegate.isLastTextField(textField) | |
let needsADone = !textField.hasKeyboardReturnKey && isLastField | |
if textField.inputView is UIPickerView || needsADone { | |
nextFieldButton.isEnabled = true | |
nextFieldButton.title = Constants.Keyboard.accessoryDoneButtonText | |
} else { | |
nextFieldButton.isEnabled = !isLastField | |
nextFieldButton.title = Constants.Keyboard.accessoryNextButtonText | |
} | |
return true | |
} | |
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { | |
textField.resignFirstResponder() | |
if textField.keyboardType == .phonePad { | |
formatPhoneNumber(in: textField) | |
} | |
delegate.checkValidation() | |
return true | |
} | |
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { | |
let didReturn = string == "\n" | |
if didReturn && delegate.isLastTextField(textField) { | |
textField.resignFirstResponder() | |
return false | |
} | |
return true | |
} | |
func textFieldShouldReturn(_ textField: UITextField) -> Bool { | |
if delegate.isLastTextField(textField) { | |
textField.resignFirstResponder() | |
lastTextFieldDidReturn() | |
} else if let currentTextFieldIndex = delegate.textFields.firstIndex(of: textField) { | |
let nextTextField = delegate.textFields[currentTextFieldIndex + 1] | |
nextTextField.becomeFirstResponder() | |
} else { | |
textField.resignFirstResponder() | |
} | |
return true | |
} | |
func textFieldDidEndEditing(_ textField: UITextField) { | |
delegate.didEditTextField(textField) | |
} | |
// MARK: - Phone Number | |
private func formatPhoneNumber(in textField: UITextField) { | |
let rawPhoneNumber = textField.text ?? "" | |
var numberText = extractNumbers(fromString: rawPhoneNumber) | |
if numberText.count >= 7 { | |
let strIndex = numberText.index(numberText.startIndex, offsetBy: 3) | |
numberText.insert("-", at: strIndex) | |
} | |
if numberText.count > 10 { | |
let strIndex = numberText.index(numberText.startIndex, offsetBy: 7) | |
numberText.insert("-", at: strIndex) | |
} | |
let maxCharCount = min(numberText.count, 12) | |
let strIndex = numberText.index(numberText.startIndex, offsetBy: maxCharCount) | |
textField.text = String(numberText[..<strIndex]) | |
} | |
private func extractNumbers(fromString str: String) -> String { | |
var numbers: String = "" | |
var remainingString = str | |
while let range = remainingString.range(of: "[0-9]{1,10}", options: .regularExpression) { | |
let numberSubstring = remainingString[range] | |
numbers += numberSubstring | |
remainingString.removeSubrange(range) | |
} | |
return numbers | |
} | |
// MARK: - Date Picker | |
lazy var datePicker: UIDatePicker = { | |
let datePicker = UIDatePicker(frame: .zero) | |
datePicker.datePickerMode = .date | |
datePicker.addTarget(self, action: #selector(datePickerChanged), for: .valueChanged) | |
return datePicker | |
}() | |
lazy var datePickerFormatter: DateFormatter = { | |
let dateFormatter = DateFormatter() | |
dateFormatter.dateStyle = .short | |
dateFormatter.timeStyle = .none | |
return dateFormatter | |
}() | |
func configureDatePicker(forTextField textField: UITextField, minDate: Date? = nil, maxDate: Date? = nil) { | |
datePicker.minimumDate = minDate | |
datePicker.maximumDate = maxDate | |
textField.inputView = datePicker | |
} | |
@objc func datePickerChanged(datePicker: UIDatePicker) { | |
guard let textField = delegate.currentResponder else { | |
return | |
} | |
let strDate = datePickerFormatter.string(from: datePicker.date) | |
textField.text = strDate | |
delegate.checkValidation() | |
} | |
private func updateDatePickerIfNeeded(_ newKeyboardFrame: CGRect) { | |
guard delegate.currentResponder?.inputView == datePicker else { | |
return | |
} | |
let size = newKeyboardFrame.size | |
let accessoryHeight = delegate.currentResponder?.inputAccessoryView?.frame.size.height ?? 0 | |
datePicker.frame = CGRect(x: 0, y: accessoryHeight, width: size.width, height: size.height - accessoryHeight) | |
} | |
// MARK: - Input Accessory | |
func useTextFieldInputAccessory() { | |
for textField in delegate.textFields { | |
textField.inputAccessoryView = customInputAccessory | |
} | |
} | |
func useDoneTextFieldInputAccessory() { | |
for textField in delegate.textFields { | |
textField.inputAccessoryView = doneInputAccessory | |
} | |
} | |
lazy var customInputAccessory: UIView = { | |
let toolBar = UIToolbar() | |
toolBar.barStyle = .default | |
toolBar.isTranslucent = true | |
toolBar.tintColor = Theme.toolbarButtonBlue | |
toolBar.backgroundColor = Theme.toolbarBackgroundGray | |
toolBar.sizeToFit() | |
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) | |
toolBar.setItems([previousFieldButton, flexibleSpace, nextFieldButton], animated: false) | |
toolBar.isUserInteractionEnabled = true | |
return toolBar | |
}() | |
lazy var doneInputAccessory: UIView = { | |
let toolBar = UIToolbar() | |
toolBar.barStyle = .default | |
toolBar.isTranslucent = true | |
toolBar.tintColor = Theme.toolbarButtonBlue | |
toolBar.backgroundColor = Theme.toolbarBackgroundGray | |
toolBar.sizeToFit() | |
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) | |
toolBar.setItems([flexibleSpace, doneKeyboardButton], animated: false) | |
toolBar.isUserInteractionEnabled = true | |
return toolBar | |
}() | |
lazy var doneKeyboardButton: UIBarButtonItem = { | |
return UIBarButtonItem( | |
title: Constants.Keyboard.accessoryDoneButtonText, | |
style: .plain, | |
target: self, | |
action: #selector(donePressed) | |
) | |
}() | |
lazy var previousFieldButton: UIBarButtonItem = { | |
return UIBarButtonItem( | |
title: Constants.Keyboard.accessoryPreviousButtonText, | |
style: .plain, | |
target: self, | |
action: #selector(previousPressed) | |
) | |
}() | |
lazy var nextFieldButton: UIBarButtonItem = { | |
return UIBarButtonItem( | |
title: Constants.Keyboard.accessoryNextButtonText, | |
style: .plain, | |
target: self, | |
action: #selector(nextPressed) | |
) | |
}() | |
@objc func donePressed() { | |
guard let textField = delegate.currentResponder else { | |
return | |
} | |
textField.resignFirstResponder() | |
} | |
@objc func nextPressed() { | |
guard let textField = delegate.currentResponder, | |
textField != delegate.lastTextField, | |
let currentTextFieldIndex = delegate.textFields.firstIndex(of: textField) else { | |
delegate.currentResponder?.resignFirstResponder() | |
return | |
} | |
let nextTextField = delegate.textFields[currentTextFieldIndex + 1] | |
nextTextField.becomeFirstResponder() | |
} | |
@objc func previousPressed() { | |
guard let textField = delegate.currentResponder, textField != delegate.textFields.first else { | |
return | |
} | |
if let currentTextFieldIndex = delegate.textFields.firstIndex(of: textField) { | |
let nextTextField = delegate.textFields[currentTextFieldIndex - 1] | |
nextTextField.becomeFirstResponder() | |
} else { | |
textField.resignFirstResponder() | |
} | |
} | |
} | |
private extension UITextField { | |
var hasKeyboardReturnKey: Bool { | |
switch self.keyboardType { | |
case .default, .asciiCapable, .URL, .namePhonePad, .emailAddress, .twitter, .webSearch: | |
return true | |
case .numbersAndPunctuation, .numberPad, .phonePad, .decimalPad, .asciiCapableNumberPad: | |
return false | |
@unknown default: | |
return false | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
protocol TextFieldsKeyboardHandlerDelegate: AnyObject { | |
var presentingView: UIView { get } | |
/// Collection of all fields in a form in the order in which they should be navigated | |
var textFields: [UITextField] { get } | |
/// Can be used to update a model when a field has been editted | |
func didEditTextField(_ textField: UITextField) | |
/// Can be used to take action once a form is completely filled out | |
func lastTextFieldDidReturn() | |
/// Can be used to update validate form and update UI elements | |
func checkValidation() | |
/// Override to return `true` if you always want to resignFirstResponder and dismiss the keyboard. | |
func isLastTextField(_ textField: UITextField) -> Bool | |
} | |
extension TextFieldsKeyboardHandlerDelegate { | |
var currentResponder: UITextField? { | |
return textFields.lazy.filter({ $0.isFirstResponder }).first | |
} | |
/// For detemining whether to dismiss the keyboard | |
var lastTextField: UITextField { | |
guard let lastTextField = textFields.last else { | |
fatalError("Must provide at least one textField") | |
} | |
return lastTextField | |
} | |
/// Redefine to return `true` if you always want to resignFirstResponder and dismiss the keyboard. | |
func isLastTextField(_ textField: UITextField) -> Bool { | |
return textField == lastTextField | |
} | |
// MARK: - Optional Functions | |
func didEditTextField(_ textField: UITextField) {} | |
func lastTextFieldDidReturn() {} | |
func checkValidation() {} | |
// MARK: - Validation | |
/// Redefine if you desire extra form validation | |
var isFormValid: Bool { | |
return textPresent(in: textFields) | |
} | |
func textPresent(in requiredFields: [UITextField]) -> Bool { | |
for textField in requiredFields where isValidTextPresent(in: textField.text) == false { | |
return false | |
} | |
return true | |
} | |
private func isValidTextPresent(in text: String?) -> Bool { | |
guard let text = text else { | |
return false | |
} | |
let trimmedText = text.trimmingCharacters(in: .whitespaces) | |
return !trimmedText.isEmpty | |
} | |
} | |
extension TextFieldsKeyboardHandlerDelegate where Self: UIViewController { | |
var presentingView: UIView { | |
return self.view | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment